Skip to content

Instantly share code, notes, and snippets.

@qstokkink
Created January 28, 2022 19:36
Show Gist options
  • Save qstokkink/4e99ff4bc19266b27f7c942dfba3a58d to your computer and use it in GitHub Desktop.
Save qstokkink/4e99ff4bc19266b27f7c942dfba3a58d to your computer and use it in GitHub Desktop.
Python Dependency Find Version
"""
Python Dependency Find Version (pydepfindver.py)
=============================================
Brute force all known versions of your pip dependencies to see which ones work for your project.
- Includes html report functionality.
- Stores (intermediate) results in files so you can resume or quickly update for new releases of dependencies.
- Uses venv.
An example for https://github.com/Tribler/py-ipv8:
# Run `run_all_tests.py -q -a -p 32` in the `E:\\py-ipv8\\` folder for every requirement in `requirements.txt`
run_all_from_requirements(['run_all_tests.py', '-q', '-a', '-p', '32'], "E:\\py-ipv8\\requirements.txt")
# After everything is done, make a nice html document.
results_to_html()
"""
import json
import os
from distutils.version import LooseVersion
import pkg_resources
import subprocess
import sys
import venv
from glob import glob
from typing import Optional, Dict, List
from urllib import request
class Environment(venv.EnvBuilder):
"""
Virtual environment to test different package versions in.
"""
def __init__(self, env_dir: str = "venv-pydepfindver"):
super().__init__(system_site_packages=False,
clear=False,
symlinks=True,
upgrade=True,
with_pip=True,
prompt="")
self.context = None
self.create(env_dir)
def ensure_directories(self, env_dir):
self.context = super().ensure_directories(env_dir)
return self.context
def install_pip_dependencies(self, requirements_path: str) -> None:
"""
Install all requirements specified in a ``requirements.txt``.
:param requirements_path: the path of the requirements.txt file
:returns: None
:raises subprocess.CalledProcessError: if installation failed
"""
cmd = [self.context.env_exe, '-m', 'pip', 'install', '--upgrade', '-r', requirements_path]
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def update_pip_dependency(self, dependency: str, version: str) -> None:
"""
Update a given dependency to a specific version.
:param dependency: the package name to set the version for
:param version: the version to set
:returns: None
:raises subprocess.CalledProcessError: if updating failed
"""
cmd = [self.context.env_exe, '-m', 'pip', 'install', '--upgrade', f"{dependency}=={version}"]
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def run_test(self, dependency: str, version: str, commands: List[str], directory: str = ".") -> bool:
"""
Update a pip dependency to a specific version and run the venv Python with given commands from some directory.
For example, one call to this method translated to bash:
.. code-block:: Python
run_test("mypackage", "1.2.3", ["arg1", 2], "/home/project")
.. code-block:: Bash
$ python -m venv venv-pydepfindver
$ pushd /home/project
$ venv-pydepfindver/Scripts/python -m pip install --upgrade mypackage==1.2.3
$ venv-pydepfindver/Scripts/python "arg1" 2
$ popd
:param dependency: the package name to set the version for
:param version: the version to set
:param commands: the (subprocess call style) args to give to the venv Python
:param directory: the working directory for the run
:return: whether the command succeeded
"""
print("> Fetching requested version")
self.update_pip_dependency(dependency, version)
cmd = [self.context.env_exe] + commands
print("> Running", repr(cmd))
try:
subprocess.run(cmd, cwd=directory, capture_output=False, stderr=subprocess.STDOUT, check=True)
except subprocess.CalledProcessError:
return False
return True
def result_file_name(dependency: str) -> str:
"""
Create a file name from a dependency name based on the Python interpreter version.
:param dependency: the dependency to create a result file name for
:return: the result file name
"""
return f"{dependency}-{hex(sys.hexversion)}.dep"
def pip_versions(dependency: str) -> Dict[str, object]:
"""
All version descriptors of a certain pip package name.
:param dependency: the package name to fetch available versions for
:return: the mapping of version names to their descriptors
"""
return json.loads(request.urlopen(f"https://pypi.org/pypi/{dependency}/json").read())['releases']
def run_tests(dependency: str,
commands: List[str],
directory: str = ".",
requirements_path: Optional[str] = None,
env_dir: str = "venv-pydepfindver") -> None:
"""
Run commands for all pip versions of a dependency.
Optionally, change the working directory, use a requirements file or customize the venv directory.
Results are cached in ".dep" files, to allow pausing and to avoid having to regenerate all results when
a dependency has a new version released.
:param dependency: the pip dependency to brute force check
:param commands: the (subprocess call style) args to give to the venv Python
:param directory: the working directory for the run
:param requirements_path: the path of the requirements.txt file
:param env_dir: the folder name for the created venv
:returns: None
"""
# Env creation
env = Environment(env_dir)
if requirements_path is not None:
env.install_pip_dependencies(requirements_path)
# Read existing results
result_path = result_file_name(dependency)
results = {}
if os.path.exists(result_path):
with open(result_path, 'r') as result_file:
results.update(json.load(result_file))
# Filter already tested
to_test = set(pip_versions(dependency).keys()) - set(results.keys())
if not to_test:
print("Skipping", dependency, "existing results leave no versions to test!")
# Run stuff
for version in to_test:
print("Running", dependency, "at version", version)
try:
success = env.run_test(dependency, version, commands, directory)
except subprocess.CalledProcessError:
# Didn't even pass setup
success = False
results[version] = success
print("SUCCESS!" if success else "FAILURE!")
# Write results
with open(result_path, 'w') as result_file:
json.dump(results, result_file)
def run_all_from_requirements(commands: List[str], requirements_path: str) -> None:
"""
Easy way to call `run_tests` for all dependencies of projects with a ``requirements.txt``.
:param commands: the (subprocess call style) args to give to the venv Python
:param requirements_path: the path of the requirements.txt file
:returns: None
"""
with open(requirements_path, "r") as requirements_file:
requirements = pkg_resources.parse_requirements(requirements_file.read())
directory = os.path.dirname(requirements_path)
for requirement in requirements:
dependency = requirement.project_name
run_tests(dependency, commands, directory=directory, requirements_path=requirements_path)
def results_to_html(output: str = "results.html") -> None:
"""
Parse the results in the cwd and turn them into an html report.
:param output: the name of the html file to write
:returns: None
"""
result_paths = sorted(glob("*.dep"))
html_output = ""
for result_path in result_paths:
with open(result_path, 'r') as result_file:
results = json.load(result_file)
html_output += f"<p style=\"word-wrap: normal; line-height: 1.8;\"><b>{result_path[:-4]}:</b><br>\n"
for key in sorted(results.keys(), key=LooseVersion):
bgcol = "lightgreen" if results[key] else "red"
html_output += (f"<span style=\"background-color: {bgcol}; border: 2px solid black; margin: 1px;"
f"padding: 2px;\">{key}</span>\n")
html_output += "</p>\n"
with open(output, "w") as output_file:
output_file.write(html_output)
@qstokkink
Copy link
Author

Example for https://github.com/Tribler/py-ipv8, invoked using:

run_all_from_requirements(['run_all_tests.py', '-q', '-a', '-p', '32'], "E:\\py-ipv8\\requirements.txt")
results_to_html()

requirements

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment