From ad0642fc89bd5b9eb68ed1d7b852410c6c5c79de Mon Sep 17 00:00:00 2001 From: Joey Riches Date: Thu, 23 Jan 2025 20:03:30 +0000 Subject: [PATCH] common/Scripts: Add get-py-deps.py **Summary** - Prints a list of python dependencies from a package.yml or a .egg-info or .dist-info path --- common/Scripts/get-py-deps.py | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 common/Scripts/get-py-deps.py diff --git a/common/Scripts/get-py-deps.py b/common/Scripts/get-py-deps.py new file mode 100644 index 00000000000..e83268f3785 --- /dev/null +++ b/common/Scripts/get-py-deps.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright © 2025 Serpent OS Developers +# +# SPDX-License-Identifier: MPL-2.0 + +import argparse +import glob +import os +import shutil +import sys +import unittest +from importlib.metadata import Distribution +from importlib.metadata import distribution +from importlib.metadata import PackageNotFoundError + +from packaging.requirements import Requirement +from packaging.markers import Marker +from ruamel.yaml import YAML +from pisi.api import fetch +from pisi.package import Package + + +SOLUS_RECIPE_FILE = ["package.yml", "package.yaml"] + +parser = argparse.ArgumentParser() +parser.add_argument("location", type=str, nargs='?', help="Location of .egg-info or .dist-info directory") + +eopkg_already_exists = False + +@staticmethod +def get_dependencies(dependencies: list, env=None) -> list: + sanitized = [] + for dep in dependencies: + req = Requirement(dep) + if not req.extras: + if req.marker: + mark = req.marker + if mark.evaluate(environment=env) is True: + sanitized.append(req.name) + continue + else: + sanitized.append(req.name) + return sanitized + +def usage(msg=None, ex=1): + if msg: + print(msg, file=sys.stderr) + else: + parser.print_help() + sys.exit(ex) + +def parse_recipe(path) -> tuple: + yaml = YAML() + with open(path, 'r') as file: + data = yaml.load(file) + + name = data.get('name') + version = data.get('version') + release = data.get('release') + + return f"{name}-{version}-{release}-1-x86_64.eopkg", name + +def init_recipe_parse_path(): + for i in SOLUS_RECIPE_FILE: + if os.path.exists(i): + eopkg_path, package_name = parse_recipe(i) + if not os.path.exists(eopkg_path): + fetch([package_name]) + else: + global eopkg_already_exists + eopkg_already_exists = True + extract_eopkg(eopkg_path) + find_python_files("install") + +def find_python_files(path): + target_dirs = [".egg-info", ".dist-info"] + + for root, dirs, files in os.walk(path): + for dir_name in dirs: + for substring in target_dirs: + if substring in dir_name: + print_dependencies(os.path.join(root, dir_name)) + +def extract_eopkg(eopkg=str): + + package = Package(eopkg) + + if not os.path.exists("install"): + os.makedirs("install") + + package.extract_pisi_files(".") + package.extract_dir("comar", ".") + if not os.path.exists(os.path.join(".", "install")): + os.makedirs(os.path.join(".", "install")) + package.extract_install(os.path.join(".", "install")) + if os.listdir("install") == []: + os.rmdir("install") + +def init_location_path(path): + if not (os.path.exists(os.path.join(path, 'METADATA')) or os.path.exists(os.path.join(path, 'PKG-INFO'))): + usage("Unable to find PKG-INFO or METADATA files in specified directory") + print_dependencies(path) + +def print_dependencies(path): + dependencies = Distribution.at(path).requires + + if dependencies: + for dep in get_dependencies(dependencies): + print(dep) + try: + dist = distribution(dep) + except PackageNotFoundError: + print(f"{dep} isn't installed!") + +if __name__ == "__main__": + args = parser.parse_args() + + if args.location and not os.path.exists(args.location): + usage() + + if not any(os.path.exists(path) for path in SOLUS_RECIPE_FILE) and args.location is None: + usage(msg="Expects a package.yml in current directory or pass a .egg-info or .dist-info path") + + if args.location is None: + init_recipe_parse_path() + else: + init_location_path(args.location) + +# Cleanup on exit +import atexit +@atexit.register +def cleanuponexit(): + if eopkg_already_exists is False: + matched_files = [file_path for file_path in glob.glob("*.eopkg")] + for i in matched_files: + os.unlink(i) + if os.path.exists("install"): + shutil.rmtree("install") + if os.path.exists("files.xml"): + os.unlink("files.xml") + if os.path.exists("metadata.xml"): + os.unlink("metadata.xml") + +class Tests(unittest.TestCase): + + def test_basic(self): + self.assertEqual(get_dependencies(['six']), ['six']) + + def test_empty_list(self): + self.assertEqual(get_dependencies([]), []) + + def test_excludes_version(self): + self.assertEqual(get_dependencies(['six>=6.9']), ['six']) + + def test_excludes_extras(self): + list1 = ['MarkupSafe>=2.0', 'Babel>=2.7; extra == "i18n"'] + self.assertEqual(get_dependencies(list1), ['MarkupSafe']) + + def test_excludes_extras_no_deps(self): + self.assertEqual(get_dependencies(['Babel>=2.7; extra == "i18n"', 'pytest; extra == "testing"']), []) + + def test_excludes_extras_comprehensive(self): + list2 = ['MarkupSafe>=0.9.2', 'Babel; extra == "babel"', 'lingua; extra == "lingua"', 'pytest; extra == "testing"'] + self.assertEqual(get_dependencies(list2), ['MarkupSafe']) + + def test_ignore_satisfied_evaluate_markers(self): + env = {'python_version': '3.11'} + list3 = ["tomli>=1.2.2; python_version < '3.11'", 'pluggy>=1.0.0'] + self.assertEqual(get_dependencies(list3, env), ['pluggy']) + + def test_included_unsatisified_evaluate_markers(self): + env = {'python_version': '3.11'} + list4 = ['pluggy', "tomli>=1.2.2; python_version < '3.12'"] + self.assertEqual(get_dependencies(list4, env), ['pluggy', 'tomli']) + + def test_comprehensive1(self): + env = {'python_version': '3.11'} + list5 = ['pytest; extra == "testing"', 'editables>=0.3', 'packaging>=21.3', 'pathspec>=0.10.1', 'pluggy>=1.0.0', "tomli>=1.2.2; python_version < '3.12'", 'trove-classifiers'] + self.assertEqual(get_dependencies(list5, env), ['editables', 'packaging', 'pathspec', 'pluggy', 'tomli', 'trove-classifiers']) + + def test_comprehensive2(self): + list6 = ["brotli; implementation_name == 'cpython'", "brotlicffi; implementation_name != 'cpython'", 'certifi', 'mutagen', 'pycryptodomex', 'requests<3,>=2.32.2', 'urllib3<3,>=1.26.17', 'websockets>=12.0', "build; extra == 'build'", "hatchling; extra == 'build'", "pip; extra == 'build'", "setuptools>=71.0.2; extra == 'build'", "wheel; extra == 'build'", "curl-cffi!=0.6.*,<0.8,>=0.5.10; (os_name != 'nt' and implementation_name == 'cpython') and extra == 'curl-cffi'", "curl-cffi==0.5.10; (os_name == 'nt' and implementation_name == 'cpython') and extra == 'curl-cffi'", "pre-commit; extra == 'dev'", "yt-dlp[static-analysis]; extra == 'dev'", "yt-dlp[test]; extra == 'dev'", "py2exe>=0.12; extra == 'py2exe'", "pyinstaller>=6.7.0; extra == 'pyinstaller'", "cffi; extra == 'secretstorage'", "secretstorage; extra == 'secretstorage'", "autopep8~=2.0; extra == 'static-analysis'", "ruff~=0.5.0; extra == 'static-analysis'", "pytest~=8.1; extra == 'test'"] + self.assertEqual(get_dependencies(list6), ['brotli', 'certifi', 'mutagen', 'pycryptodomex', 'requests', 'urllib3', 'websockets'])