Skip to content

Add --exclude <package name> argument support to pip install. #13724

@AraHaan

Description

@AraHaan

What's the problem this feature will solve?

I have a unique use case where I must exclude a specific dependency from pip that is requested to be installed by a dependency of mine in my requirements.txt. This could be done with --no-deps, however it comes with a few known problems.

1: I always use py -3.14 -m pip install --upgrade -r requirements.txt and adding --no-deps to it and manually listing their dependencies can break if they add or remove required dependencies (for example a dependency of aiohttp)
2: adding --no-deps is the best idea when one only wants to exclude a single dependency from install (in my case audioop-lts installed via discord.py as I embed python and bundle audioop inside of the exe itself via PyImport_AppendInitTab (c api)
3: The way I embed python and then package it up in a stub exe (SFX exe) is via a package I made which makes a dummy dist folder for constructing the single file exe, it installs the site packages the same as in 1, but adds -t dist/site-packages. If -t was allowed in pip uninstall audioop-lts I would not be complaining right now as I then in that package take all the contents in the dist/site-packages folder and compress them into dist/build_tmp/site-packages.zip.
4: if the above site-packages.zip contains the audioop-lts package with the audioop module that I compile into the exe itself there is a 99% certainty that conflicts will take place when trying to import audioop.

Describe the solution you'd like

One of the following would satisfy the requirements to fix this issue:

  • Adding --exclude <package name> as a valid option in pip install that can be used to filter out a specific package(s) from installing WITHOUT requiring the user to use --no-deps (this would ignore the requirement that would be inside of any of the dependencies requirements.txt and will not try to pull in the excluded dependencies requirements.txt for installing its requirements).
    • discord.py
      • audioop-lts (excluded)
        • some-dep (excluded due to audioop-lts being excluded)
      • aiohttp
      • other deps that would be listed here but snipped for easier reading.
  • Adding -t <output path> as a valid option to pip uninstall as well where it explicitly tells pip uninstall to look to uninstall the package from that specific folder.

Alternative Solutions

Alternatively although it would be flaky and dangerous to do would be to have my package which sets up the embedded python program to check the dist/site-packages folder before zipping it for an audioop and the audioop-lts.dist-info folders and deleting them (which could break). This is not the best solution outside of having pip handle this for me to begin with. Also it would benefit others to have this feature implemented as it does not seem like a good idea to me to not have an --exclude <package name> that can exclude specific packages without having to resort to --no-deps and being forced to manage the dependencies of your dependencies yourself.

Additional context

The way my package installs the site packages is within "DistBuilder.py" here:
import os
import platform
import urllib.request
import zipfile
import argparse
import shutil
import subprocess
import py_compile
from pathlib import Path
from ._resourceediting import replace_resources


__all__ = ['DistBuilder']


class DistBuilder:
    @staticmethod
    def detect_architecture() -> str:
        """
        Returns the correct architecture string for the CPython embeddable zip.
        Output: 'win32' or 'amd64' or 'arm64'
        """
        machine = platform.machine().lower()
        if "arm" in machine:
            return "arm64"

        if "64" in machine:
            return "amd64"

        return "win32"

    @staticmethod
    def download_and_extract_cpython(version: str, target_dir: str):
        """
        Downloads the CPython embeddable ZIP for the detected architecture
        and extracts it into the target directory.
        """
        target_dir = os.path.join(target_dir, "build_tmp")
        arch = DistBuilder.detect_architecture()
        filename = f"python-{version}-embed-{arch}.zip"
        url = f"https://www.python.org/ftp/python/{version}/{filename}"
        os.makedirs(target_dir, exist_ok=True)
        zip_path = os.path.join(os.path.join(os.path.dirname(__file__), 'python_cache'), filename)
        print(f"Detected architecture: {arch}")
        if not os.path.exists(zip_path):
            os.makedirs(os.path.join(os.path.dirname(__file__), 'python_cache'), exist_ok=True)
            print(f"Downloading {url} ...")
            urllib.request.urlretrieve(url, zip_path)
            print(f"Downloaded to {zip_path}")
        else:
            print(f"Using cached {zip_path}")

        allowed_exts = {".pyd", ".dll", ".zip"}
        print("Extracting...")
        with zipfile.ZipFile(zip_path, "r") as z:
            for member in z.infolist():
                _, ext = os.path.splitext(member.filename.lower())
                if ext in allowed_exts:
                    z.extract(member, target_dir)

        print(f"Extracted to {target_dir}")

    @staticmethod
    def write_data_files(target_dir: str, program_name: str, stub_name: str):
        # use the __file__ filled in by the import system as the package's path.
        files_to_copy = {
            f'{os.path.dirname(__file__)}/embed.exe': f'{target_dir}/build_tmp/{program_name}.exe',
            f'{os.path.dirname(__file__)}/stub.exe': f'{target_dir}/{stub_name}.exe',
            f'{os.path.dirname(__file__)}/memimport.py': f'{target_dir}/site-packages/memimport.py',
            f'{os.path.dirname(__file__)}/zipextimporter.py': f'{target_dir}/site-packages/zipextimporter.py',
        }

        for src, dst in files_to_copy.items():
            print(f'{src} -> {dst}')
            os.makedirs(os.path.dirname(dst), exist_ok=True)
            shutil.copy2(src, dst)

    @staticmethod
    def install_requirements(version: str, target_dir: str, requirements_file: str):
        print(f'Ensuring Python {version} is installed...')
        subprocess.check_output(['py', 'install', version], text=True)
        subprocess.check_output([
            'py', f'-{version}', '-m', 'pip', 'install', '--upgrade', '-r', requirements_file,
            '-t', os.path.join(target_dir, 'site-packages')], text=True)

        # automatically uninstall audioop-lts as the embed exe already has
        # a version of audioop that is compiled into it.
        subprocess.check_output([
            'py', f'-{version}', '-m', 'pip', 'uninstall', 'audioop-lts',
            '-t', os.path.join(target_dir, 'site-packages')], text=True)
        print(f"Dependencies installed into {os.path.join(target_dir, 'site-packages')}")

    @staticmethod
    def _compile_one_py(src, dest, name, optimize=2, checked=True) -> Path:
        if dest is not None:
            dest = str(dest)

        mode = (
            py_compile.PycInvalidationMode.CHECKED_HASH
            if checked
            else py_compile.PycInvalidationMode.UNCHECKED_HASH
        )

        try:
            return Path(
                py_compile.compile(
                    str(src),
                    dest,
                    str(name),
                    doraise=True,
                    optimize=optimize,
                    invalidation_mode=mode,
                )
            )
        except py_compile.PyCompileError:
            return None

    @staticmethod
    def _make_zip(root_folder: str, target_dir: str, name: str, support_pyd_packing: bool = False,
                  support_png_packing: bool = False):
        output = f'{os.path.join(target_dir, name)}.zip'
        full_name = os.path.join(root_folder, name)
        pathlist = [path for path in Path(full_name).rglob('*.py')]
        pathlist_other = [path for path in Path(full_name).rglob('*.pyi')]
        [pathlist_other.append(path) for path in Path(full_name).rglob('*.typed')]
        if support_png_packing:
            [pathlist_other.append(path) for path in Path(full_name).rglob('*.png')]
        # for when packing all site-packages to zip also include the ".dist-info" folders.
        # [pathlist_other.append(path) for path in Path(full_name).rglob('*.dist-info/*')]
        if support_pyd_packing:
            [pathlist_other.append(path) for path in Path(full_name).rglob('*.pyd')]
            [pathlist_other.append(path) for path in Path(full_name).rglob('*.dll')]
        os.mkdir(f'{full_name}_tmp')
        with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
            for path in pathlist:
                pyc = DistBuilder._compile_one_py(
                    f'{path.parents[0]}/{path.name}',
                    f'{str(path.parents[0]).replace(name, f"{full_name}_tmp")}/{path.name + "c"}',
                    path.name)
                if pyc:
                    try:
                        parents = str(path.parents[0]).replace(
                            name, '') if name == 'site-packages' or name == 'lib' else path.parents[0]
                        zf.write(str(pyc), f'{parents}/{path.name + "c"}')
                    finally:
                        try:
                            pyc.unlink()
                        except:
                            pass
            for path in pathlist_other:
                parents = str(path.parents[0]).replace(
                    name, '') if name == 'site-packages' or name == 'lib' else path.parents[0]
                zf.write(str(path), f'{parents}/{path.name}')

            shutil.rmtree(f'{full_name}_tmp', ignore_errors=True)

    @staticmethod
    def _make_zip2(target_dir: str, name, _path):
        output: str = f'{os.path.join(target_dir, name)}.zip'
        pathlist = [path for path in _path.rglob('*.*')]
        with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
            for path in pathlist:
                zf.write(str(path), f'{path.name}')

    @staticmethod
    def write_pth_file(target_dir: str, package_name: str, version: str):
        """ Writes <package_name>._pth into build_tmp with the correct paths for the embeddable Python distribution. """
        build_tmp = os.path.join(target_dir, "build_tmp")
        os.makedirs(build_tmp, exist_ok=True)
        major, minor, *_ = version.split(".")
        pth_path = os.path.join(build_tmp, f'{package_name}._pth')
        contents = (
            f"python{major}.{minor}.zip\n"
            "site-packages.zip\n"
            ".\n"
        )

        with open(pth_path, "w", encoding="utf-8") as f:
            f.write(contents)

    @staticmethod
    def main():
        parser = argparse.ArgumentParser(
            description="Embed python with a specific CPython runtime version.",
            prefix_chars="--")
        parser.add_argument(
            "version",
            nargs="?",
            default="3.14.2",
            help="Python version to download (default: 3.14.2)")
        parser.add_argument(
            "target_dir",
            nargs="?",
            default=os.path.join(os.getcwd(), "dist"),
            help="Directory where files should be extracted (default: ./dist)")
        parser.add_argument(
            "icon_file",
            help="The icon file to use in the embed and stub exes")
        parser.add_argument(
            "console_title",
            help="The text to use for the console title in the embed exe")
        parser.add_argument(
            "package_name",
            help="The package name to use to run the embed exe")
        parser.add_argument(
            "requirements",
            nargs="?",
            default=None,
            help="The path to the requirements.txt file that is used to install site-packages for the embed exe to use."
        )
        args = parser.parse_args()
        DistBuilder.download_and_extract_cpython(args.version, args.target_dir)
        if args.package_name and args.console_title and args.icon_file is not None:
            DistBuilder.write_data_files(args.target_dir, args.package_name, args.console_title)
            replace_resources(
                f'{args.target_dir}/build_tmp/{args.package_name}.exe',
                None,
                args.icon_file,
                None,
                None,
                False)
            if args.requirements is not None:
                DistBuilder.install_requirements(args.version, args.target_dir, args.requirements)

            DistBuilder._make_zip(
                args.target_dir,
                os.path.join(args.target_dir, 'build_tmp'),
                'site-packages',
                support_pyd_packing=True)

            DistBuilder.write_pth_file(args.target_dir, args.package_name, args.version)
            DistBuilder._make_zip2(
                args.target_dir,
                'files',
                Path(os.path.join(args.target_dir, 'build_tmp')))
            replace_resources(
                f'{os.path.join(args.target_dir, args.console_title)}.exe',
                f'{os.path.join(args.target_dir, "files")}.zip',
                args.icon_file,
                args.console_title,
                args.package_name,
                True)

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions