4 Dependencies

Last edited

Version Specifiers

  • It’s a good idea to include its current version as a lower bound.
  • Avoid guessing upper version bounds—​you shouldn’t guard against newer releases unless you know they’re incompatible with your project (lock files are for this purpose).
[project]
dependencies = ["httpx>=0.27.0", "rich>=13.7.1"]

# You can skip versions (if there's a broken version of a bug fix)
dependencies = ["awesome>=1.2,!=1.3.1"]

#You can pin to upper bounds but AVOID THIS
dependencies = ["awesome>=1.2,<2"]

Version operators

"httpx==0.28.1"     # exactly 0.28.1
"httpx!=0.28.1"     # any version except 0.28.1
"httpx>=0.27.0"     # 0.27.0 or newer
"httpx<=0.28.1"     # 0.28.1 or older
"httpx>0.27.0"      # strictly newer than 0.27.0
"httpx<0.28.1"      # strictly older than 0.28.1
"httpx~=0.27.0"     # 0.27.x only, not 0.28.0
"httpx===0.28.1"    # arbitrary string equality, no normalization

Python environments can contain only a single version of each package. Libraries that put upper bounds prevent downstream projects from receiving security and bug fixes.

Extras

Optional Dependencies

I’ve upgraded the httpx code to use http2, but wisely the devs decided to make http2 an “extra” dependancy so it’s optional to install.

with httpx.Client(headers=headers, http2=True) as client:
ImportError: Using http2=True, but the 'h2' package is not installed. Make sure to install httpx using `pip install httpx[http2]`.

So to add the extra in my pyproject.toml

[project]
dependencies = ["httpx[http2]>=0.27.0", "rich>=13.7.1"]

httpx’s perspective

[project]
name = "httpx"

[project.optional-dependencies]
http2 = ["h2>=3,<5"]
brotli = ["brotli"]

How do we use this in our code if it’s optional for users?

try:
    import h2
except ImportError:
    h2 = None

# Check h2 before use.
if h2 is not None:
    ...

Environment Markers

Environment markers purpose: in your pyproject.toml you can request different dependencies for:

  • specific operating systems
  • processor architectures
  • Python implementations
  • Python versions

Example

Here’s a full example, we don’t want to force linux users to install a library if they don’t need it but windows users do need it.

[project]
dependencies = [
  "colorama; sys_platform == 'win32'"
]

Then the code:

import sys

if sys.platform == "win32":
    from colorama import init
    init()

print("\033[31mRed text\033[0m")

better practice for larger differences

project/
├── terminal.py
├── terminal_windows.py
└── terminal_unix.py
import sys

if sys.platform == "win32":
    from .terminal_windows import *
else:
    from .terminal_unix import *

Development Dependencies

Why do we need to seperate dev dependencies?

Development dependencies are third-party packages that you require during development but NOT during runtime. An example would be pytest obviously users have no use for they just run the code not the tests. So we don’t want to make them install this, thus it’s meant for development.

why do need explicitly declare these?

You could just have a readme and tell devs what to install but you also need to share compatible versions

Each of your projects may have slightly different requirements. Multiply this by the number of developers working on each project, and it becomes clear that you need a way to track your development dependencies.

How do we set these up?

How it used to be

There was a hacky way with [project.optional-dependencies]:

[project.optional-dependencies]
tests = [
    "pytest",
]
uv pip install -e ".[tests]"

How this is done now [dependency-groups] ~ 2024

https://packaging.python.org/en/latest/specifications/dependency-groups/

We have a new table we can use for this with more advanced features:

[dependency-groups]
test = [
    "pytest",
]

dev = [
    { include-group = "test" },
    "ruff",
]

Locking Dependencies

You’ve installed your dependencies in a local environment or in continuous integration (CI), and you’ve run your test suite and any other checks you have in place. Everything looks good, and you’re ready to deploy your code. But how do you install the same packages in production that you used when you ran your checks?

Reasons why environments end up with different packages given the same dependency specifications:

  • A new release comes in before you deploy.
  • you can get different packages if your development environment doesn’t match the production environment
    • environment markers might pick up a different package

You need a way to define the exact set of packages required by your application, and you want its environment to be an exact image of this package inventory. This process is known as locking, or pinning, the project dependencies, which are listed in a lock file.

Locking is also beneficial during development, for both applications and libraries. By sharing a lock file with your team and with contributors, you put everybody on the same page: every developer uses the same dependencies when running the test suite, building the documentation, or performing other tasks.

Using the lock file for mandatory checks avoids surprises where checks fail in CI after passing locally. To reap these benefits, lock files must include development dependencies, too.

Question

why not narrow the version constraints in pyproject.toml?

Answer

  1. you’ve lost valuable information: the compatible version ranges for your top-level dependencies.
  2. you’ve locked only direct dependencies

Freezing Dependencies

uv

uv lock
# creates uv.lock

uv sync 
# installs the exact same environment

You then can commit this up to the repo.