Invoking Poetry from a Script
We have a common python script that is used for doing various development tasks. The script has sub commands similar to git or poetry.
For example, if our script is named foo.py
, we might have commands like:
foo.py make-coffee --with-cream --with-sugar
foo.py get-lunch --vegan
The details of what it does isn’t really important, just that we have this python script, it depends on various python packages, and we don’t use a virtual environment. Insert every shock and awe emoji you can think of here
Since this post is about poetry and the development script, hopefully you can see that I’m trying to rectify the no virtual environment practice. I wanted a virtual environment tool that would handle pinning transitive dependencies in a somewhat automatic fashion. There are a few other solutions such as pipenv and pip-tools. For our uses I think any of these may be viable options.
I want to ease the transition to virtual environments as much as possible. The team is not comprised of python developers. We need to support code branches going back years, so we want to make it easy for developers to jump back and forth between branches without having to directly interact with too many oddities of each branch.
All that to say, I would like to avoid devlopers having to do:
poetry run foo.py make-coffee
in some branches, but then having to do:
foo.py make-coffee
in other branches. One might say “just pull the changes into the other branches”. You’ll have to trust me when I tell you that, with our current tooling, it’s not as practical as it could be.
The Investigation
With the desire of keeping the developer workflow to invoking foo.py
directly,
I decided to investigate how one might invoke poetry from a wrapper
script.
When poetry is installed it’s installed as an executable. Knowing
what little I know about python packaging that implied to me that when it’s
packaged the executable is a generated wrapper for a script. So I did some
quick digging into the poetry repo and found the pyproject.toml
with the following entry:
[tool.poetry.scripts]
poetry = "poetry.console.application:main"
This implies to me that there is a main()
function in
poetry.console.application
and sure enough I found:
From prior experience with argparse, I’m looking for the use of argv
or
sys.argv
. My thought is that I should somehow be able to invoke the main
poetry entry point and pass it arguments. I could probably get away
with overriding sys.argv
and calling Application().run()
in my script, but
if possible I would like a way to pass arguments.
Even though I was using VSCode in the browser in the Github repo, I wasn’t able
to jump directly to the implementation of run()
, so I did the more manual
process. In the same file there was an Application
class that derives from
BaseApplication
.
Looking at the top of the file there was a nice import
Cleo
I hadn’t ran across Cleo before. It’s another take on a command line parser. Now that I’m investing more in virtual environments, maybe I’ll finally start looking at things outside of argparse.
Cleo’s release at the time of this writing is 0.8.1
, while their
mainline has a pre-release. So one must navigate through the 0.8.1
version as
that is what poetry depends on.
Navigating through the Cleo repo I found the Application
class
This derives from ConsoleApplication
which comes from clikit,
another package maintained by the same people as Cleo.
Looking at the run()
method on ConsoleApplication
one can see:
Only the first part of the method is displayed
The args
variable and ArgvArgs()
call look promising. Navigating to the
definition for ArgvArgs
:
There is the sys.argv
I was looking for. It looks like if nothign is passed
to the ArgvArgs()
constructor than sys.argv
will be used. However one can
pass in a list of arguments to not use sys.argv
.
Hypothesis
I’m thinking I can create a clikit.args.argv_args.ArgvArgs
instance. During
the creation of this ArgvArgs
I can pass in
["poetry", "run", "my_script.py"]
as the argv
parameter. Then pass this
ArgvArgs
into poetry’s Application().run()
method.
The Test
For the foo.py
script we’ll have:
For the test I’ll use an alternative script to launch foo.py
. I’ll call this
bar.py
:
The Results
First I tried to run foo.py
directoy with python and poetry to
verify my logic.
Running foo.py
directly with python resulted in the expected "NO VENV SET"
.
Trying to run foo.py
directly with poetry via poetry run foo.py
failed. It looks like poetry will try to run this as an application,
so I had to change my command slightly to poetry run python foo.py
. Adding
python
to the command resulted in outputing the path to a virtual environment
in my home directory.
In retrospect I failed to read the poetry documentation correctly. If one reads the documentation for Using poetry run it shows an example of
poetry run python your_script.py
With that knowledge, bar.py
was modified slightly:
Invoking bar.py
directly provided the path to a virtual environment in my home
directory, just like when I did poetry run python foo.py
.
Polishing The BootStrapper
In my mind bar.py
is a sort of bootstrapper. It seems like it could be
reusable for other scripts that need to leverage poetry. This will require a
little bit of rework.
The First Refactor Pass
The new contents of bar.py
:
bar.py
is now a module with functions and it will no longer be invokable
directly from the command line. It has a new function launch()
, which is
provided the name of the script to launch. Passing in the script is important,
as this will prevent circular dependencies and make the bootstrapper re-usable
to other scripts.
One important piece to note is the VIRTUAL_ENV
check, this should prevent
recursion from foo.py
. We also use sys.exit()
at the bottom, which means
that launch()
will try to exit the python process when outside of a virtual
environment. If a caller really wanted to, they could catch SystemExit
.
Admitedly I’m not sure if I like this sys.exit()
usage, but I like the idea of
callers not needing to have conditional logic around their use of launch()
.
The thought did briefly creep into the back of my mind to do the logic when
importing bar.py
. My better judgement prevailed here. It sounds nice
initially, but importing with side effects seems to never play out well in the
long run.
The new version of foo.py
adds the importing of bar.py
and calling the new
launch()
function:
One may notice that it looks like this script will always print the
VIRTUAL_ENV
variable, however bar.launch()
will exit when foo.py
is
invoked outside of a virtual environmeent. As mentioned above, this might be
too magical of an interface, only time will tell.
The Second Refactor Pass
The next refactor is going to ensure that all arguments get passed into
launch()
, not just the script name.
Even though the command may be poetry run python foo.py
, when the python
executable runs it will only provide foo.py
, and any trailing arguments, in
sys.argv
. This means we can read sys.argv
directly as if foo.py
was
invoked on the command line.
Like the ArgvArgs
from clikit, launch()
can have the arguments
optionally passed in. If they aren’t provided then they will be grabbed from
sys.argv
.
The change to foo.py
is minor. Removal of passing the script name:
The Last Refactor
As I mentioned before, most of the team members aren’t python developers and I want this transition to be as seemless as possible for them. In order to streamline this change I would like to avoid extra steps for the developers when they move to a newer version of the code base. This means I’m going to do something that is debatable, the script will be installing poetry if it’s not there yet.
This will install a specific version of poetry if the user doesn’t have it
installed yet. It will install into the --user
directory to avoid admin
permissions and to make it easier to clean up later if need be.
After we ensure_poetry
is installed there is a check against the version.
While this is a bit restrictive, I don’t think any developers will have poetry
pre-installed so this will get us by for a time. I’ll leave any compatibility
issues for future me. Initially in researching this, I dug through
Cleo’s unreleased version. There are going to be some changes coming to
how arguments are passed in the Application
class, so this specific
implementation probably won’t work on some future version of poetry.
Summary
It is possible to wrap up poetry and invoke it from within another python module. While some of my current solution may be debatable, I’m hoping that we can transition to using virtual environments without too much manual user setup. I will admit that I’m fairly new to poetry, so there may be a better way or even a documented way to achieve a similar solution. I just didn’t see anything using my google fu.
The bootstrap script is fairly minimal to add to any standard python script:
If you decide to copy this idea, I would suggest renaming from
bar.py
.