How to package your Python programs using the setup.py method

Your repo setup should be this:

# This is a tree of my repo for lsimg, a python program I wrote

β”œβ”€β”€  .gitignore
β”‚
β”œβ”€β”€ ξ˜‹ config.yaml
β”‚
β”œβ”€β”€ ο„• onboardme
β”‚   └── ξ˜† __init__.py
β”‚
β”œβ”€β”€ ο€– MANIFEST.in
β”‚
β”œβ”€β”€ ξ˜‰ README.md
β”‚
β”œβ”€β”€ ξ˜† setup.cfg
β”‚
└── ξ˜† setup.py

Important files

.gitignore

For the python stuff you don’t want in git, such as:

**/__pycache__/**
**/*.egg-info/**

bin/{COMMAND}

The bin directory is where I keep my cli script that I want to end up in the user’s $PATH. As an example, the command my users run for lsimg is lsimg, so I create a file called that which has something like:

#!/usr/bin/env python3.12
import lsimg

lsimg.main()

__init__.py

File that has your actual script.

setup.cfg

toml file for you to define various data about your project, but this is where I put the license info. additional license info.

[metadata]
# This includes the license file(s) in the wheel.
# https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file
license_files = LICENSE.txt

setup.py

You need this for creating packages. Here’s my basic setup.py for a package called onboardme that I wrote. You can see that the only function is the readme function, which adds the readme as the longer description for pypi usage.

Other things to note that many forget are keywords and classifiers, which help other users find your package.

def readme():
    """
    grab and return contents of README.md for use in long description
    """
    with open('README.md') as f:
        return f.read()


lic_class = ('License :: OSI Approved :: GNU Affero General Public License v3'
             'or later (AGPLv3+)')

setup(name='onboardme',
      description='An onboarding tool to install dot files and packages',
      long_description=readme(),
      long_description_content_type='text/markdown',
      classifiers=['Development Status :: 3 - Alpha',
                   'Programming Language :: Python :: 3.12',
                   'Operating System :: MacOS :: MacOS X',
                   'Operating System :: POSIX :: Linux',
                   'Intended Audience :: End Users/Desktop',
                   'TOPIC :: SYSTEM :: INSTALLATION/SETUP',
                   lic_class],
      python_requires='>3.12',
      keywords='onboardme, onboarding, desktop-setup, setuptools, development',
      version='0.13.7',
      project_urls={
          'Documentation': 'https://jessebot.github.io/onboardme/onboardme',
          'Source': 'http://github.com/jessebot/onboardme',
          'Tracker': 'http://github.com/jessebot/onboardme/issues'},
      author='jessebot',
      author_email='jessebot@linux.com',
      license='GPL version 3 or later',
      packages=['onboardme'],
      install_requires=['wget', 'GitPython', 'PyYAML', 'rich', 'click'],
      data_files=[('config', ['config/config.yml',
                              'config/packages.yml',
                              'config/brew/Brewfile_Darwin',
                              'config/brew/Brewfile_Linux',
                              'config/brew/Brewfile_devops'])],
      entry_points={'console_scripts': ['onboardme = onboardme:main']},
      include_package_data=True,
      zip_safe=False)

Quick note on versioning (the setup(version='') bit above): Please use semantic versioning.

MANIFEST.in - For Including non-python files

This file lets you specify files that aren’t python files for your package, otherwise only files ending in .py will be included in your package. Here’s my default MANIFEST.in file:

include README.md
include LICENSE.txt

You don’t have to include config.yaml. I just typically have a default config file and it’s always yaml.

packaging command line scripts

That’s where this bit from the above setup.py comes in:

entry_points={'console_scripts': ['onboardme = onboardme:main']},

It translates to: Create a command called onboardme that calls the main function of the onboardme package.

Testing the package

Note: I’m using python/pip version 3.12 explicitly here, but you could use any version you’re testing/releasing.

Build locally first for general testing:

# this will install locally and then you can test your package and cli tools
pip3.12 install -e .

Then do the more important build for files to uplaod to pypi: Make sure you have the wheel, and twine module installed:

  • pip3.12 install wheel
  • pip3.12 install twine
# this generates a wheel file, the thing you want to upload
python3.12 -m build --wheel

# this checks to make sure it's probably built correctly
twine check dist/*

Assuming the above all worked, then you can finally upload to pypi (make sure you have a .pypirc with your token in it as described here.

# this does a final check before upload and will fail if you have an incorrect classifer
twine upload dist/*

Note: if the classifers broke, you can check them against the official page.

Other helpful guides