Python Packaging & Executables on Windows
By: Joseph D. Marhee
Tags: Python Programming, System Administration, Programming, Windows, Python

I recently began developing on, and for Windows, and while I continue to develop my skills in Powershell and C#, I rely on my go-to language for most day-to-day automation, Python. I found that there were many marked differences between the experience of writing system programs for Unix-like systems, and for Windows, most of which I won’t go into in this post, but to highlight one that recently resulted in a little bit of poking around, writing Python packages and installing executable scripts via pip on Windows required becoming a bit more familiar with this part of the Python ecosystem.

Your typical Python package has a structure like:

Project

-> setup.py

-> package_name/__init__.py

-> package_name/package.py

-> bin/package_name

where, once your module is build, the script in bin imports it, and is, itself, installed to your PATH per your setup.py which might look like this:

from setuptools import setup setup(name='package_name', version='0.1', ... packages=['package_name'], scripts=['bin/package_name'], install_requires=[ ... ], zip_safe=False)

and on Unix-like systems, this would be all that is required, you might not even bother with an entry_points dictionary for less complex packages with a handful of single functions, and most other routing for functions might happen from a CLI tool package like argparse .

However, when you build a package like this on Windows, you can, of course, execute the installed script, referencing the PATH to that script, but it won’t be interpretted correctly as a Python script as-is:

PS C:\Users\you\src> package_name

Program 'package_name' failed to run: No application is associated with the specified file for this operationAt line:1 char:1

+ wslrun

+ ~~~~~~.

At line:1 char:1

+ ~~~~~~

+ CategoryInfo : ResourceUnavailable: (:) [], ApplicationFailedException

+ FullyQualifiedErrorId : NativeCommandFailed

or executing the path to the script directly, i.e.:

C:\Python38\Scripts\package_name

A correct, and commonly executable method of doing this without creating aliases, etc. is using the entry_points option I mentioned a moment ago, where you notate a command and map to a function. One limitation is that these entry point console scripts do not allow you to define arguments, however, you can handle them, all the same.

Let’s assume bin\package_name had something like:

import package_name import sys, getopt, os try: opts, args = getopt.getopt(sys.argv[1:],"hi:o:", ["path =",""]) except getopt.GetoptError: print("-p 'path'") sys.exit(2) for opt, arg in opts: if opt in ("-p", "--path"): path= arg print(package_name.main(path))

in it, in order to handle arguments to route user input to some function in your package_name/main.py file that’s imported at the beginning of this script.

To handle this, both, more correctly for Windows, and in a more condensed fashion, this behavior can be moved to your main script, like so:

def main(): try: opts, args = getopt.getopt(sys.argv[1:],"hi:o:", ["path =",""]) except getopt.GetoptError: print("-p 'path'") sys.exit(2) for opt, arg in opts: if opt in ("-p", "--path"): path= arg print(main(path))

with the following caveats:

main() should not accept arguments, either, so you’ll need a package like argparse (I used optparse here, but anything passed from sys.argv[1:] — all positional arguments from the first on- can be read by your package when we update the command in setup.py in a moment) You’ll no longer need a line like if __name__=="__main__": in the package script (unless you want it, but for this use case, it’s not really necessary) Your setup.py will need to be updated.

In your setup.py to create this mapping, you’ll need to remove the line:

# scripts=['bin/package_name'],

or comment it out (as I did above), and add:

entry_points = { "console_scripts": [ "command_name = package_name:main", ] },

So, when you proceed to build the package:

pip install -e .

You can then run a command like this in PowerShell to see where the executable was dropped:

PS C:\Users\you\src> Get-Command command_name.exe



CommandType Name Version Source

----------- ---- ------- ------

Application command_name.exe 0.0.0.0 C:\Users\you\AppData\Local\Programs\Python\Python38\Scripts\command_name.exe

where command_name is whatever you named the command in the setup.py file, and you can proceed to use your command by calling it:

PS C:\Users\you\src> command_name.exe --path whatever.txt

Additional Resources

Some excellent reading if you’re new to Python packaging in general:

python-packaging

Hitchhiker’s Guide to Python Packaging

Python Apps the Right Way