Skip to content

Commit e9526e7

Browse files
committed
Add post "Controlling transitive dependencies"
Post 2 in "Know what you install"
1 parent 6d50b9f commit e9526e7

File tree

1 file changed

+266
-0
lines changed

1 file changed

+266
-0
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
---
2+
title: "Know what you install: Controlling transitive dependencies"
3+
date: 2023-11-06T16:16:12+01:00
4+
ShowToc: true
5+
tags: ["know-what-you-install"]
6+
draft: true
7+
---
8+
9+
> This post is the second in a series that attempts to show several potential
10+
> issues when installing Python packages and explain solutions to them.
11+
>
12+
> In this post, we will discuss transitive dependencies a bit more, and how to
13+
> control versions for them.
14+
>
15+
> [You can find all posts in the series here](/tags/know-what-you-install/)
16+
17+
Transitive dependencies are dependencies defined by your direct dependencies.
18+
19+
To give an example:
20+
21+
- Your project depends on package _A_.
22+
- _A_ in turn depends directly on _B_ and _D_.
23+
- B depends on _C_.
24+
- Both _B_ and _D_ depends on _E_.
25+
26+
```goat
27+
A -+----> B --> C
28+
| |
29+
'-> D -'-> E
30+
```
31+
32+
In this example, _A_ is a direct dependency, and the rest are transitive
33+
dependencies.
34+
35+
Transitive dependency versions are controlled by the dependency that pulls them
36+
in, and pip will try to find a version that is compatible with all constraints
37+
_see [Dependency resolution]_.
38+
39+
In the above examples, FastAPI depended on Pydantic and might restrict
40+
which Pydantic versions that are compatible.
41+
42+
We can look in the [pyproject.toml file for FastAPI][fast-toml] and see that
43+
there are several restrictions in place for Pydantic.
44+
45+
```toml {linenostart=42,hl_lines=[3]}
46+
dependencies = [
47+
"starlette>=0.27.0,<0.28.0",
48+
"pydantic>=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0",
49+
"typing-extensions>=4.8.0",
50+
# TODO: remove this pin after upgrading Starlette 0.31.1
51+
"anyio>=3.7.1,<4.0.0",
52+
]
53+
```
54+
55+
This mean that even if we have a direct dependency of Pydantic and therefore
56+
want to install Pydantic and FastAPI together we will have problems for the
57+
versions `fastapi==0.104.1` and `pydantic==2.0.0`.
58+
59+
```shell {linenos=false}
60+
$ python3 -m pip install fastapi==0.104.1 pydantic==2.0.0
61+
62+
[ output omitted ]
63+
64+
ERROR: Cannot install fastapi==0.104.1 and pydantic==2.0.0 because these
65+
package versions have conflicting dependencies.
66+
67+
The conflict is caused by:
68+
The user requested pydantic==2.0.0
69+
fastapi 0.104.1 depends on pydantic!=1.8, !=1.8.1, !=2.0.0, !=2.0.1,
70+
!=2.1.0, <3.0.0 and >=1.7.4
71+
72+
To fix this you could try to:
73+
1. loosen the range of package versions you've specified
74+
2. remove package versions to allow pip attempt to solve the dependency
75+
conflict
76+
77+
ERROR: ResolutionImpossible: for help visit
78+
https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
79+
```
80+
81+
The only reasonable way to solve this is to relax one of your version
82+
constraints so that they are compatible. If you remove your pin on Pydantic,
83+
pip will figure out a version that is compatible with both your and FastAPI's
84+
requirements.
85+
86+
However, if you do not specify a version you cannot be sure that the same
87+
Pydantic version will be installed every time as other direct dependencies can
88+
restrict the version.
89+
90+
Compare these two installations, the first we installed FastAPI version 0.104.1
91+
together with an unpinned Pydantic:
92+
93+
```shell {linenos=false,hl_lines=[6]}
94+
$ python3 -m pip install 'fastapi==0.104.1' pydantic
95+
96+
[ output omitted ]
97+
98+
Successfully installed annotated-types-0.6.0 anyio-3.7.1 idna-3.4
99+
fastapi-0.104.1 pydantic-2.4.2
100+
pydantic-core-2.10.1 sniffio-1.3.0 starlette-0.27.0
101+
typing-extensions-4.8.0
102+
```
103+
104+
Here we got Pydantic 2.4.2. However, if we install the same version of FastAPI
105+
with an unpinned Pydantic and an unpinned SQLModel.
106+
107+
```shell {linenos=false,hl_lines=[6]}
108+
$ python3 -m pip install 'fastapi==0.104.1' pydantic sqlmodel
109+
110+
[ output omitted ]
111+
112+
Successfully installed SQLAlchemy-1.4.50 anyio-3.7.1
113+
fastapi-0.104.1 pydantic-1.10.13
114+
greenlet-3.0.1 idna-3.4 sniffio-1.3.0
115+
sqlalchemy2-stubs-0.0.2a36 sqlmodel-0.0.11 starlette-0.27.0
116+
typing-extensions-4.8.0
117+
```
118+
119+
As can be seen by the example above, we get a different version of Pydantic
120+
because SQLModel had a different restrictions. This is all fine, this is how
121+
you expect a dependency resolver to function. If we are picky about a package
122+
version we need to introduce version constraints and might run into scenarios
123+
where installation is not possible.
124+
125+
{{< box info >}}
126+
Transitive dependencies version constraints are fully in the control of our
127+
direct dependencies, unless we bring them in as direct dependencies.
128+
{{</ box >}}
129+
130+
[fast-toml]: https://github.com/tiangolo/fastapi/blob/0.104.1/pyproject.toml#L42-L48
131+
132+
# Specifying transitive dependency versions
133+
134+
To achieve stability in deployed code or services, it is important to ensure
135+
that the same dependency versions get installed every time. For example, it
136+
would be frustrating if tests passes in CI, but crashes in production only
137+
because a new version of a transitive dependency has been released.
138+
139+
Therefore, we want to specify version of transitive dependencies as well, but
140+
as shown above, we need to find a version that is compatible with our other
141+
dependencies.
142+
143+
A naive approach is to run `pip freeze` after installing the packages in a
144+
local environment. To re-use the environment above where we installed FastAPI,
145+
Pydantic and SQLModel, we see a list of eleven installed packages.
146+
147+
```shell {linenos=false}
148+
$ python3 -m pip freeze
149+
anyio==3.7.1
150+
fastapi==0.104.1
151+
greenlet==3.0.1
152+
idna==3.4
153+
pydantic==1.10.13
154+
sniffio==1.3.0
155+
SQLAlchemy==1.4.50
156+
sqlalchemy2-stubs==0.0.2a36
157+
sqlmodel==0.0.11
158+
starlette==0.27.0
159+
typing_extensions==4.8.0
160+
```
161+
162+
However, maintaining this list manually is very hard as it is not clear which
163+
of the dependencies here are our direct dependencies and which are transitive
164+
dependencies.
165+
166+
Instead, it is wise to use a tool built for this, for example [pip-compile from
167+
pip-tools][PT]. The tool takes an input file such as `pyproject.toml`
168+
(recommended) or `requirements.in` and generates a text file with all
169+
direct and transitive dependencies pinned.
170+
171+
In the example below we use a `requirements.in` file for simplicity, with the
172+
following content:
173+
174+
```text
175+
fastapi==0.104.1
176+
pydantic
177+
sqlmodel
178+
```
179+
180+
Running `pip-compile` will generate a "compiled" requirements file that can be
181+
saved to e.g., `requirements.txt`.
182+
183+
```shell {linenos=false}
184+
$ pip-compile requirements.in
185+
186+
[ output omitted ]
187+
#
188+
# This file is autogenerated by pip-compile with Python 3.12
189+
# by the following command:
190+
#
191+
# pip-compile requirements.in
192+
#
193+
anyio==3.7.1
194+
# via
195+
# fastapi
196+
# starlette
197+
fastapi==0.104.1
198+
# via -r requirements.in
199+
greenlet==3.0.1
200+
# via sqlalchemy
201+
idna==3.4
202+
# via anyio
203+
pydantic==1.10.13
204+
# via
205+
# -r requirements.in
206+
# fastapi
207+
# sqlmodel
208+
sniffio==1.3.0
209+
# via anyio
210+
sqlalchemy==1.4.50
211+
# via sqlmodel
212+
sqlalchemy2-stubs==0.0.2a36
213+
# via sqlmodel
214+
sqlmodel==0.0.11
215+
# via -r requirements.in
216+
starlette==0.27.0
217+
# via fastapi
218+
typing-extensions==4.8.0
219+
# via
220+
# fastapi
221+
# pydantic
222+
# sqlalchemy2-stubs
223+
```
224+
225+
This setup is much easier to manage that `pip freeze` as you can control your
226+
dependencies in one file and then run `pip compile` to generate a new file with
227+
all direct and transitive dependencies pinned.
228+
229+
# Dependency resolution speeds up installs
230+
231+
As mentioned earlier in this post, when you install packages pip needs to
232+
figure out a version that that is compatible with all available version
233+
constraints (from direct or transitive dependencies).
234+
235+
Historically, pip had a very naive approach, but the dependency resolver
236+
introduced in 2020 improved this process and it now takes all constraints into
237+
consideration.
238+
239+
However, this process can be slow, especially if there are many constraints to
240+
take into consideration. A pre-compiled file improves this as dependency
241+
resolution needs to happen only when the file is generated and not during each
242+
install.
243+
244+
For more information about how to use tox to compile dependencies in a
245+
convenient way, see my post _[Compile and use dependencies for multiple Python
246+
versions in Tox]({{< ref "compile-and-use-dependencies-for-multiple-python-versions-in-tox.md" >}})_.
247+
248+
{{< box warning >}}
249+
Problem 2: Transitive dependency versions are hard to control manually
250+
{{</ box >}}
251+
252+
{{< box tip >}}
253+
Solution 2: Compile all your dependencies with `pip-compile` from pip-tools
254+
255+
- Compile direct and transitive dependencies into pinned versions with
256+
`pip-compile`.
257+
- Use the following flags with Pip-tools version 7.x: `--allow-unsafe`,
258+
`--resolver=backtracking`, `--strip-extras` ([will be default in the next
259+
major release ](https://github.com/jazzband/pip-tools#deprecations)).
260+
- Check in the compiled files into git and install from this during deployment.
261+
- _(if you are using tox)_: Set up a tox env to make it easier for developers
262+
to run the compilation with the correct Python version.
263+
{{</ box >}}
264+
265+
[PT]: https://github.com/jazzband/pip-tools
266+
[Dependency resolution]: {{< ref "know-what-you-install--install-a-package.md#dependency-resolution" >}}

0 commit comments

Comments
 (0)