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