-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Description
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 inpip installthat 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.
- audioop-lts (excluded)
- discord.py
- Adding
-t <output path>as a valid option topip uninstallas well where it explicitly tellspip uninstallto 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
- I agree to follow the PSF Code of Conduct.