Search code examples
pythonpython-3.xinstallationpipnamespaces

Python package with optional namespace sub-packages


Problem

I am struggling to create a single entry point for installing a python package that leverages namespace sub-package to allow users to optionally download additional modules. Below is the piece I am struggling with in this example. I have also provided additional context below as well to clarify the problem.

starwars\setup.py [Doesn't work]

import setuptools

setuptools.setup(
    name="starwars",
    packages=setuptools.find_namespace_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent"
    ],
    python_requires=">=3.7",
    install_requires=[
        'common'
    ],
    extra_requires={
        'characters': ['characters'],
        'weapons': ['weapons']
    }
)
$ pwd
> ~/starwars

$ pip install .
> ERROR: No matching distribution found for common

$ pip install .[characters]
> zsh: no matches found: .[characters]

$ pip install .[weapons]
> zsh: no matches found: .[weapons]

Project Goal

I am trying to create a python package with optional namespace sub-package dependencies that I can install from a private git repo. Below is an example of what the commands would look like.

# Installs only the common subpackage
$ pip install -e git+https://github.com/user/project.git#egg=starwars
# OR
$ $ pip install -e .

# Installs the common and characters subpackage
$ pip install -e git+https://github.com/user/project.git#egg=starwars[characters]
# OR
$ $ pip install -e .[characters]

# Installs only the common and weapons subpackage
$ pip install -e git+https://github.com/user/project.git#egg=starwars[weapons]
# OR
$ $ pip install -e .[weapons]

Project Structure

\starwars
-- setup.py

-- common
  |-- setup.py
  |-- starwars
     |-- utils
     |-- abstract

-- characters (Optional)
  |-- setup.py
  |-- starwars
     |-- jedi
     |-- sith
     |-- senators

-- weapons (Optional)
  |-- setup.py
  |-- starwars
     |-- blaster
     |-- lightsabers

starwars\common\setup.py

import setuptools


setuptools.setup(
    name="common",
    packages=setuptools.find_namespace_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent"
    ],
    python_requires=">=3.7",
    install_requires=[
        "asyncio",
        "turtle"
    ]
)

starwars\characters\setup.py

import setuptools


setuptools.setup(
    name="characters",
    packages=setuptools.find_namespace_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent"
    ],
    python_requires=">=3.7",
    install_requires=['common']
)

starwars\weapons\setup.py

import setuptools


setuptools.setup(
    name="weapons",
    packages=setuptools.find_namespace_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent"
    ],
    python_requires=">=3.7",
    install_requires=['common']
)

Current Status

I have successfully setup the native namespace sub-packages and can install them individually by using the commands below.

$ pwd
> ~/starwars

# Installing the common package
$ pushd ./common
$ pip install .
$ python -c 'import starwars.utils;'
$ popd

# Installing the characters package
$ pushd ./characters
$ pip install .
$ python -c 'import starwars.jedi;'
$ popd

# Installing the weapons package
$ pushd ./weapons
$ pip install .
$ python -c 'import starwars.lightsabers'
$ popd

References


Solution

  • Here is the setup.py that worked for me. I got inspiration from this post

    starwars/setup.py

    import subprocess
    from setuptools import setup
    from setuptools.command.install import install
    
    
    try:
        import pypandoc
        long_description = pypandoc.convert_file('README.md', 'rst')
    except(IOError, ImportError):
        long_description = open('README.md').read()
    
    
    class InstallLocalPackage(install):
        description = "Installs the subpackage specified"
        user_options = install.user_options + [
            ('extra=', None, '<extra to setup package with>'),
        ]
    
        def initialize_options(self):
            install.initialize_options(self)
            self.db = None
    
        def finalize_options(self):
            assert self.db in (None, 'characters', 'weapons'), 'Invalid extra!'
            install.finalize_options(self)
    
        @staticmethod
        def install_subpackage(subpackage_dir: str):
            dir_map = {
                'common': './common',
                'characters': './characters',
                'weapons': './weapons'
            }
    
            subprocess.call(
                f"pushd ./{dir_map[subpackage_dir]}; "
                f"pip install .;"
                f"popd;",
                shell=True
            )
    
        def install_subpackages(self):
            if self.db is None:
                [self.install_subpackage(package) for package in ['common', 'characters', 'weapons']]
            else:
                [self.install_subpackage(package) for package in ['common', self.db]]
    
        def run(self):
            install.run(self)
            self.install_subpackages()
    
    
    
    setup(
        name="starwars",
        version_format='{tag}.{commits}',
        setup_requires=['very-good-setuptools-git-version'],
        author_email="[email protected]",
        description="Star Wars Python Package",
        long_description=long_description,
        long_description_content_type="text/markdown",
        classifiers=[
            "Programming Language :: Python :: 3",
            "License :: OSI Approved :: MIT License",
            "Operating System :: OS Independent"
        ],
        python_requires=">=3.7",
        cmdclass={ 'install': InstallLocalPackage },
        install_requires=[
            "pandas",
            "asyncio"
        ]
    )