Seven months ago I tweeted about Pipenv:
That was about it.
The project- which was at v3.6.1 at the time – still felt new. At Jamf, we were working on our strategy for handling project requirements and dependencies going forward for Jamf Cloud’s automation apps and Pipenv, while a cool idea, didn’t have maturity and traction that.
As @elios pointed out on the MacAdmins Slack: Pipenv was announced in January of this year as an experimental project.
Today it is the officially recommended Python packaging tool from Python.org.
In his words, “That’s awfully fast…”
The GitHub repository for Pipenv has 5400+ stars and over 100+ contributors to the project. It is proceeding at breakneck development speeds and adding all kinds of juicy features every time you turn around. It’s on v8.3.2.
Within ten minutes of finally, FINALLY, giving Pipenv a try today I made the conscious decision to chuck the tooling we had made and convert all of our projects over.
(Oh, and the person who wrote that tooling wholly agreed)
Let’s talk about that.
Part 1: Why requirements.txt sucks
is was this standard workflow to your Python projects. You needed two key tools: pip and virtualenv. When developing, you would create a Python virtual environment using virtualenv in order to isolate your project from the system Python or other virtual environments.
You would then use pip to install all the packages your project needs. Once you had all of those packages installed you would then run a command to generate a requirements.txt file that listed every package at the exact installed version.
The requirements.txt file stays with your project. When someone else gets a copy of the repository to work on it or deploy it they would follow the same process of creating a virtual environment and then using pip to install all of the required packages by feeding that requirements.txt file into it.
Now, that doesn’t seem so bad, right? The drawbacks become apparent as you begin to craft your build pipelines around your projects.
Maintenance is a Manual Chore
The first thing to call out is how manual this process is. You need to create the same version virtual environment for the project once you’ve cloned it and then install the packages. If you want to begin upgrading packages you need to be sure to manually export a new requirements.txt file each time.
Removing packages is far more problematic. Uninstalling with pip will not remove dependencies for a package! Take Flask, for example. It’s not just one package. It has four sub-packages that get installed with it, and one of those (Jinja2) has its own sub-package.
So, maybe the answer is to manually maintain only the top-level packages in your requirements.txt file? That’s a hard no. You don’t want to do that because it makes your environment no longer deterministic. What do I mean? I mean, those sub-packages we just talked about are NOT fixed versions. When you’re installing Python packages from the Python Package Index (PyPI) the aren’t using requirements.txt files with fixed versions for their dependencies. They instead are specifying ranges allowing for installs at a minimum and/or maximum version for that sub-package.
This makes a lot of sense when you think about it. When installing a package for the first time as a part of a new project, or updating a package in an existing project, you are going to want the latest allowed versions for all the sub-packages for any patches that have been made.
Not so much when it comes to a project/application. You developed and tested your code at specific package versions that are a known working state. This is past the development phase when bugs from updates to those packages can be identified and addressed. When it comes to deployment, your project’s requirements.txt file must be deterministic and list every package at the exact version.
Hence, maintenance of this file becomes a manual chore.
But Wait, There’s Testing!
There are a lot of packages that you might need to install that have nothing to do with running your Python application, but they have everything to do with running tests and building documentation. Common package requirements for this would by Pytest, Tox, and Sphinx with maybe a theme. Important, but not needed in a deployment.
We want to be able to specify a set of packages to install that will be used during the testing and build process. The answer unfortunately, is a second requirements.txt file. This one would have a special name like requirements-dev.txt which is only used during a build. This file could only contain the specific packages and be installed with pip after the standard requirements.txt, or it could contain all of those plus the build packages. In either case, the maintenance problem continues to grow.
So We Came Up With Something…
Our process at Jamf ended up settling on three types of requirements.txt files in an effort to address all the shortcomings described.
This file contained the full package list for the project at fixed versions. It would be used for spinning up new development environments or during deployment.
This file contained the top-level packages without fixed versions. Development environments would not be built from this. Instead, this file would be used for recreating the standard requirements.txt file at updated package versions and eliminating orphaned packages that were no longer used or removed from the project at some point.
This is a manually maintained file per-project that only contained the additional packages needed during the build process.
This approach isn’t too dissimilar to what others have implemented for many of the same reasons we came up with.
Part 2: Enter Pipfile
Not Pipenv? We’re getting there.
Pipfile was introduced almost exactly a year ago at the time of this post (first commit on November 18th, 2016). The goal of Pipfile is to replace requirements.txt and address the pain points that we covered above.
Here is what a Pipfile looks like:
[source] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [dev-packages] [packages] [requires] python_version = "2.7"
PyPI is the default source in all new Pipfiles. Using the scheme shown, you can add additional [source] sections with unique names to specify your internal Python package indexes (we have our own at Jamf for shared packages across projects).
There are two groups for listing packages: dev-packages and packages. In a Pipfile these lists follow how we were handling this where only the build packages go into the dev-packages list and all project packages go under the standard packages list.
Packages in the Pipfile can have fixed versions or be set to install whatever the latest version is.
[packages] flask = "*" requests = "==2.18.4"
The [requires] section dictates the version of Python that the project is meant to run under.
From the Pipfile you would create what is called a Pipfile.lock which contains all of the environment information, the installed packages, their installed versions, and their SHA256 hashes. The hashes are a recent security feature of pip to validate packages that are installed when deployed. If there is a hash mismatch you can abort. A powerful security tool in preventing malicious from entering your environments.
Note that you can specify the SHA256 hashes of packages in a normal requirements.txt file. This is a feature of pip and not of Pipfile.
It is this Pipfile.lock that will be used to generate environments on other systems whether for development or deployment. The Pipfile will be used for maintaining packages and dependencies and regenerating your Pipfile.lock.
All of this is just a specification. Pip as of yet still does not support Pipfiles, but there is another..
Part 3: Pipenv Cometh
Pipenv is the implementation of the Pipfile standard. It is built on top of pip and virtualenv and manages both your environment and package dependencies, and it does so like a boss.
Install Pipenv with pip (or homebrew if that’s your jam).
$ pip install pipenv
Then in a project directory create a virtual environment and install some packages!
$ pipenv --two Creating a virtualenv for this project… <...install output...> Virtualenv location: /Users/me/.local/share/virtualenvs/test-zslr3BOw Creating a Pipfile for this project… $ pipenv install flask Installing flask… <...install output...> Adding flask to Pipfile's [packages]… Locking [dev-packages] dependencies… Locking [packages] dependencies… Updated Pipfile.lock (36eec0)! $ pipenv install requests==2.18.4 Installing requests==2.18.4… <...install output...> Adding requests==2.18.4 to Pipfile's [packages]… Locking [dev-packages] dependencies… Locking [packages] dependencies… Updated Pipfile.lock (572f23)! $
Notice how we see the Locking messages after every install? Pipenv automatically regenerated the Pipfile.lock each time the Pipfile is modified. Your fixed environment is being automatically maintained!
Graph All The Things
Let’s look inside the Pipfile itself.
[source] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [dev-packages] [packages] flask = "*" requests = "==2.18.4" [requires] python_version = "2.7"
No sub-packages? Nope. It doesn’t need to track those (they end up in the Pipfile.lock, remember?). But, if you’re curious, you can use the handy graph feature to view a full dependency tree of your project!
$ pipenv graph Flask==0.12.2 - click [required: >=2.0, installed: 6.7] - itsdangerous [required: >=0.21, installed: 0.24] - Jinja2 [required: >=2.4, installed: 2.9.6] - MarkupSafe [required: >=0.23, installed: 1.0] - Werkzeug [required: >=0.7, installed: 0.12.2] requests==2.18.4 - certifi [required: >=2017.4.17, installed: 2017.11.5] - chardet [required: >=3.0.2,<3.1.0, installed: 3.0.4] - idna [required: >=2.5,<2.7, installed: 2.6] - urllib3 [required: <1.23,>=1.21.1, installed: 1.22] $
Check that out! Notice how you can see the requirement that was specified for the sub-package in addition to the actual installed version?
Environment Management Magic
Now let’s uninstall Flask.
$ pipenv uninstall flask Un-installing flask… <...uninstall output...> Removing flask from Pipfile… Locking [dev-packages] dependencies… Locking [packages] dependencies… Updated Pipfile.lock (4ddcaf)! $
And re-run the graph command.
$ pipenv graph click==6.7 itsdangerous==0.24 Jinja2==2.9.6 - MarkupSafe [required: >=0.23, installed: 1.0] requests==2.18.4 - certifi [required: >=2017.4.17, installed: 2017.11.5] - chardet [required: >=3.0.2,<3.1.0, installed: 3.0.4] - idna [required: >=2.5,<2.7, installed: 2.6] - urllib3 [required: <1.23,>=1.21.1, installed: 1.22] Werkzeug==0.12.2
Yes, the sub-packages have now been orphaned within the existing virtual environment, but that’s not the real story. If we look inside Pipfile we’ll see that requests is the only package listed, and if we look inside our Pipfile.lock we will see that only requests and it’s sub-packages are present.
We can regenerate our virtual environment cleanly with only a few commands!
$ pipenv uninstall --all Un-installing all packages from virtualenv… Found 20 installed package(s), purging… <...uninstall output...> Environment now purged and fresh! $ pipenv install Installing dependencies from Pipfile.lock (f58d9f)… 🐍 ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 5/5 — 00:00:01 To activate this project's virtualenv, run the following: $ pipenv shell $ pipenv graph requests==2.18.4 - certifi [required: >=2017.4.17, installed: 2017.11.5] - chardet [required: >=3.0.2,<3.1.0, installed: 3.0.4] - idna [required: >=2.5,<2.7, installed: 2.6] - urllib3 [required: <1.23,>=1.21.1, installed: 1.22] $
Installing the dev-packages for our builds uses an additional flag with the install command.
$ pipenv install sphinx --dev Installing sphinx… <...install output...> Adding sphinx to Pipfile's [dev-packages]… Locking [dev-packages] dependencies… Locking [packages] dependencies… Updated Pipfile.lock (d7ccf2)!
The appropriate locations in our Pipfile and Pipefile.lock have been updated! To install the dev environment perform the same steps for regenerating above but add the –dev flag.
$ pipenv install --dev Installing dependencies from Pipfile.lock (f58d9f)… 🐍 ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 18/18 — 00:00:05 To activate this project's virtualenv, run the following: $ pipenv shell
Part 4: Deploy Stuff!
The first project I decided to apply Pipenv to in order to learn the tool is ODST. While there is a nice feature in Pipenv where it will automatically import a requirements.txt file if detected, I opted to start clean and install all my top-level packages directly. This gave me a proper Pipfile and Pipfile.lock.
Here’s the resulting Pipfile.
[source] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [dev-packages] pytest = "*" sphinx = "*" sphinx-rtd-theme = "*" [packages] flask = "*" cryptography = "*" celery = "*" psutil = "*" flask-sqlalchemy = "*" pymysql = "*" requests = "*" dicttoxml = "*" pyjwt = "*" flask-login = "*" redis = "*" [requires] python_version = "2.7"
Here’s the graph of the installed packages (without the dev-packages).
$ pipenv graph celery==4.1.0 - billiard [required: >=184.108.40.206,<3.6.0, installed: 220.127.116.11] - kombu [required: <5.0,>=4.0.2, installed: 4.1.0] - amqp [required: >=2.1.4,<3.0, installed: 2.2.2] - vine [required: >=1.1.3, installed: 1.1.4] - pytz [required: >dev, installed: 2017.3] cryptography==2.1.3 - asn1crypto [required: >=0.21.0, installed: 0.23.0] - cffi [required: >=1.7, installed: 1.11.2] - pycparser [required: Any, installed: 2.18] - enum34 [required: Any, installed: 1.1.6] - idna [required: >=2.1, installed: 2.6] - ipaddress [required: Any, installed: 1.0.18] - six [required: >=1.4.1, installed: 1.11.0] dicttoxml==1.7.4 Flask-Login==0.4.0 - Flask [required: Any, installed: 0.12.2] - click [required: >=2.0, installed: 6.7] - itsdangerous [required: >=0.21, installed: 0.24] - Jinja2 [required: >=2.4, installed: 2.9.6] - MarkupSafe [required: >=0.23, installed: 1.0] - Werkzeug [required: >=0.7, installed: 0.12.2] Flask-SQLAlchemy==2.3.2 - Flask [required: >=0.10, installed: 0.12.2] - click [required: >=2.0, installed: 6.7] - itsdangerous [required: >=0.21, installed: 0.24] - Jinja2 [required: >=2.4, installed: 2.9.6] - MarkupSafe [required: >=0.23, installed: 1.0] - Werkzeug [required: >=0.7, installed: 0.12.2] - SQLAlchemy [required: >=0.8.0, installed: 1.1.15] psutil==5.4.0 PyJWT==1.5.3 PyMySQL==0.7.11 redis==2.10.6 requests==2.18.4 - certifi [required: >=2017.4.17, installed: 2017.11.5] - chardet [required: >=3.0.2,<3.1.0, installed: 3.0.4] - idna [required: >=2.5,<2.7, installed: 2.6] - urllib3 [required: <1.23,>=1.21.1, installed: 1.22]
In my Dockerfiles for the project I swapped out using requirements.txt and switched it to install my packages using the Pipefile.lock into the system Python (in a containerized app there’s no real need to create a virtual environment).
RUN /usr/bin/apt-get update -q && \ /usr/bin/apt-get install -qqy build-essential git && \ /usr/bin/apt-get install -qqy python-pip python-dev && \ /usr/bin/pip install pipenv && \ /usr/bin/apt-get install -qqy libssl-dev libffi-dev && \ /usr/bin/apt-get install -qqy uwsgi uwsgi-plugin-python && \ /usr/bin/apt-get clean && \ /bin/rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* COPY /docker/webapp/web-app.ini /etc/uwsgi/apps-enabled/ COPY /Pipfile* /opt/odst/ WORKDIR /opt/odst/ RUN /usr/local/bin/pipenv install --system --deploy COPY /ods/ /opt/odst/ods/ COPY /application.py /opt/odst/ COPY /docker/ods_conf.cfg /opt/odst/ RUN /bin/chown -R www-data:www-data /opt/odst/ods/static CMD ["/usr/bin/uwsgi", "--ini", "/etc/uwsgi/apps-enabled/web-app.ini"]
The –system flag tells Pipenv to install to the system Python. The –deploy flag will abort the installation if the Pipfile.lock is out of date or the Python version is wrong. Out of date? Pipenv knows if your Pipfile.lock matches up with the Pipfile by comparing a SHA256 hash of the Pipfile that it has saved. If there’s a mismatch then there is an issue and it aborts.
If I didn’t want to rely on this hash match feature, I could instead pass a –ignore-pipfile flag that will tell it to proceed using only the Pipfile.lock.
I should mention at this point that Pipenv dramatically speeds up build times. When using pip your packages will install sequentially. Pipenv will install packages in parallel. The difference is immediately noticeable, especially when regenerating your development environments.
Still, The World is Catching Up
Python development workflows area clearly moving to this tool, but Pipenv is less than a year old at this point and not everything will support generating environments from the provided Pipfiles.
That is thankfully a simple task. You can generate a requirements.txt file from Pipenv by using the lock option.
$ pipenv lock --requirements > requirements.txt
For Elastic Beanstalk applications, I can have this command run as a part of my build pipeline so the file is included with the ZIP archive before it goes to S3.
In the case of Read the Docs there is an open issue for adding Pipfile support, but until then I will need to generate the requirements.txt file as I make changes to my environment and save it with the repository.
For Read the Docs I’ll want the extra dev-packages. This is done by using the pipenv run command. This will execute the succeeding string as if it were run in the environment.
$ pipenv run pip freeze > requirements.txt
The bright side is that I am no longer actually managing this file. It is straight output from my Pipenv managed environment!
Part 5: Profit
I hope you enjoyed my overview and impressions of Pipenv. It’s a tool that is going to have a huge impact on development and deployment workflows – for the better!
A huge shout out to Kenneth Reitz for creating yet another invaluable package for the Python community!
* Edit Nov 9: Changed the command for exporting a requirements.txt file. Using pipenv lock -r -d only outputs the packages under the dev-packages section.