├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── Makefile ├── README.md ├── action.yml ├── list_python_dependencies.py ├── pyproject.toml ├── requirements-linting.txt └── test-case └── pyproject.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: set up python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.11' 22 | 23 | - run: pip install -r requirements-linting.txt 24 | 25 | - uses: pre-commit/action@v3.0.0 26 | with: 27 | extra_args: --all-files 28 | 29 | test_action: 30 | runs-on: ubuntu-latest 31 | 32 | outputs: 33 | PYTHON_DEPENDENCY_CASES: ${{ steps.list-python-dependencies.outputs.PYTHON_DEPENDENCY_CASES }} 34 | 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - uses: samuelcolvin/list-python-dependencies@main 39 | id: list-python-dependencies 40 | with: 41 | max_cases: 10 42 | path: ./test-case/ 43 | 44 | # also try in "first-last" mode, note we don't actually use this for the matrix step below 45 | - uses: samuelcolvin/list-python-dependencies@main 46 | with: 47 | mode: first-last 48 | path: ./test-case/ 49 | 50 | test_matrix: 51 | runs-on: ubuntu-latest 52 | 53 | needs: 54 | - test_action 55 | 56 | strategy: 57 | matrix: 58 | PYTHON_DEPENDENCY_CASE: ${{ fromJSON(needs.test_action.outputs.PYTHON_DEPENDENCY_CASES) }} 59 | 60 | name: testing ${{ matrix.PYTHON_DEPENDENCY_CASE }} 61 | steps: 62 | - uses: actions/checkout@v3 63 | 64 | - name: set up python 65 | uses: actions/setup-python@v4 66 | with: 67 | python-version: '3.11' 68 | 69 | - run: pip install ${{ matrix.PYTHON_DEPENDENCY_CASE }} 70 | - run: pip freeze 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | env/ 3 | venv/ 4 | .venv/ 5 | env3*/ 6 | *.lock 7 | *.py[cod] 8 | *.egg-info/ 9 | /sandbox/ 10 | /.ruff_cache/ 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | 10 | - repo: local 11 | hooks: 12 | - id: lint 13 | name: Lint 14 | entry: make lint 15 | types: [python] 16 | language: system 17 | pass_filenames: false 18 | - id: pyupgrade 19 | name: Pyupgrade 20 | entry: pyupgrade --py311-plus 21 | types: [python] 22 | language: system 23 | exclude: ^docs/.*$ 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | COPY ./pyproject.toml /app/pyproject.toml 4 | COPY ./list_python_dependencies.py /app/ 5 | COPY ./action.yml /app/ 6 | 7 | RUN pip install /app/ 8 | 9 | CMD ["list_python_dependencies"] 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := lint 2 | sources = list_python_dependencies.py 3 | 4 | .PHONY: install 5 | install: 6 | python -m pip install -U pip 7 | pip install -r requirements-linting.txt 8 | pip install -e . 9 | 10 | .PHONY: format 11 | format: 12 | isort $(sources) 13 | black $(sources) 14 | 15 | .PHONY: lint 16 | lint: 17 | ruff $(sources) 18 | isort $(sources) --check-only --df 19 | black $(sources) --check --diff 20 | 21 | .PHONY: pyupgrade 22 | pyupgrade: 23 | pyupgrade --py311-plus $(sources) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # List Python Dependencies 2 | 3 | [![CI](https://github.com/samuelcolvin/list-python-dependencies/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/list-python-dependencies/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) 4 | 5 | GitHub action to list all valid versions of dependency for a Python project. 6 | 7 | This action is designed to allow all (or a random sample) of package dependency versions to be tested without manual configuration. 8 | 9 | ## Usage 10 | 11 | Example usage: 12 | 13 | ```yaml 14 | jobs: 15 | # this job does just one thing - it builds a set of test cases by inspecting either 16 | # `pyproject.toml` or `setup.py` for package dependencies 17 | find_dependency_cases: 18 | runs-on: ubuntu-latest 19 | 20 | outputs: 21 | PYTHON_DEPENDENCY_CASES: ${{ steps.list-python-dependencies.outputs.PYTHON_DEPENDENCY_CASES }} 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: samuelcolvin/list-python-dependencies@main 26 | id: list-python-dependencies 27 | with: 28 | # if you want to limit the number of cases tested, set `max_cases` here 29 | # if omitted, all cases will be tested 30 | max_cases: 10 31 | 32 | # this is the main test job, the only special thing about it `strategy.matrix` which is 33 | # generated from the output of `find_dependency_cases` above 34 | test_matrix: 35 | runs-on: ubuntu-latest 36 | 37 | needs: 38 | - find_dependency_cases 39 | 40 | strategy: 41 | matrix: 42 | PYTHON_DEPENDENCY_CASE: ${{ fromJSON(needs.find_dependency_cases.outputs.PYTHON_DEPENDENCY_CASES) }} 43 | 44 | name: testing ${{ matrix.PYTHON_DEPENDENCY_CASE }} 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - name: set up python 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: '3.11' 52 | 53 | # set up the environment for the test case as you usually would 54 | - run: pip install -e . 55 | - run: pip install -r tests/requirements.txt 56 | # install specific versions of dependencies using `matrix.PYTHON_DEPENDENCY_CASE` 57 | - run: pip install ${{ matrix.PYTHON_DEPENDENCY_CASE }} 58 | - run: pytest 59 | ``` 60 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: List Python Dependencies 2 | author: Samuel Colvin 3 | description: List all valid versions of dependency for a Python project 4 | 5 | inputs: 6 | max_cases: 7 | description: Maximum number of combined dependencies to list, defaults to no limit 8 | 9 | path: 10 | description: Path to the project, defaults to the current directory 11 | default: . 12 | 13 | mode: 14 | description: | 15 | Can be with "first-last" - only create cases for the earlier earliest and latest versions of each dependency, 16 | or "all" - create cases for all versions of each dependency. 17 | default: all 18 | 19 | runs: 20 | using: docker 21 | image: Dockerfile 22 | 23 | branding: 24 | icon: arrow-right-circle 25 | color: orange 26 | -------------------------------------------------------------------------------- /list_python_dependencies.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import re 5 | import tomllib 6 | from pathlib import Path 7 | 8 | import requests 9 | from packaging.requirements import Requirement 10 | 11 | __version__ = '0.0.2' 12 | __all__ = ('list_python_dependencies',) 13 | 14 | 15 | def list_python_dependencies(): 16 | path = Path(os.getenv('INPUT_PATH') or '.').expanduser().resolve() 17 | 18 | max_cases_v = os.environ.get('INPUT_MAX_CASES') 19 | if max_cases_v: 20 | max_cases = int(max_cases_v) 21 | else: 22 | max_cases = None 23 | 24 | first_last = os.getenv('INPUT_MODE', '').strip().lower() == 'first-last' 25 | 26 | print(f'list-dependencies __version__={__version__!r} path={path!r} max_cases={max_cases} first_last={first_last}') 27 | deps = load_deps(path) 28 | valid_versions = get_valid_versions(deps) 29 | print('') 30 | cases = get_test_cases(valid_versions, max_cases=max_cases, first_last=first_last) 31 | for case in cases: 32 | print(f' {case}') 33 | print('') 34 | 35 | github_output = os.getenv('GITHUB_OUTPUT') 36 | env_name = 'PYTHON_DEPENDENCY_CASES' 37 | if github_output: 38 | json_value = json.dumps(cases) 39 | print('Setting output for future use:') 40 | print(f' {env_name}={json_value}') 41 | with open(github_output, 'a') as f: 42 | f.write(f'{env_name}={json_value}\n') 43 | else: 44 | print(f'Warning: GITHUB_OUTPUT not set, cannot set {env_name}') 45 | 46 | 47 | class OptionalRequirement(Requirement): 48 | pass 49 | 50 | 51 | def load_deps(path: Path) -> list[Requirement]: 52 | py_pyroject = path / 'pyproject.toml' 53 | if py_pyroject.exists(): 54 | with py_pyroject.open('rb') as f: 55 | pyproject = tomllib.load(f) 56 | deps = [Requirement(dep) for dep in pyproject['project']['dependencies']] 57 | option_deps = pyproject['project'].get('optional-dependencies') 58 | if option_deps: 59 | for extra_deps in option_deps.values(): 60 | deps.extend([OptionalRequirement(dep) for dep in extra_deps]) 61 | return deps 62 | 63 | setup_py = path / 'setup.py' 64 | if setup_py.exists(): 65 | setup = setup_py.read_text() 66 | m = re.search(r'install_requires=(\[.+?])', setup, flags=re.S) 67 | if not m: 68 | raise RuntimeError('Could not find `install_requires` in setup.py') 69 | install_requires = eval(m.group(1)) 70 | deps = [Requirement(dep) for dep in install_requires] 71 | m = re.search(r'extras_require=(\{.+?})', setup, flags=re.S) 72 | if m: 73 | for extra_deps in eval(m.group(1)).values(): 74 | deps.extend(OptionalRequirement(dep) for dep in extra_deps) 75 | return deps 76 | 77 | raise RuntimeError(f'No {py_pyroject.name} or {setup_py.name} found in {path}') 78 | 79 | 80 | OMIT_SENTINEL = '[omit]' 81 | 82 | 83 | def get_valid_versions(deps: list[Requirement]) -> dict[str, list[str]]: 84 | session = requests.Session() 85 | valid_versions: dict[str, list[str]] = {} 86 | for dep in deps: 87 | resp = session.get(f'https://pypi.org/pypi/{dep.name}/json') 88 | resp.raise_for_status() 89 | versions = resp.json()['releases'].keys() 90 | compat_versions = [v for v in versions if v in dep.specifier] 91 | if not compat_versions: 92 | raise RuntimeError(f'No compatible versions found for {dep.name}') 93 | if isinstance(dep, OptionalRequirement): 94 | compat_versions.append(OMIT_SENTINEL) 95 | valid_versions[dep.name] = compat_versions 96 | return valid_versions 97 | 98 | 99 | def get_test_cases(valid_versions: dict[str, list[str]], max_cases: int | None, first_last: bool) -> list[str]: 100 | min_versions = [(name, versions[0]) for name, versions in valid_versions.items()] 101 | cases: list[tuple[tuple[str, str], ...]] = [] 102 | 103 | for name, versions in valid_versions.items(): 104 | add_versions = [] 105 | if first_last: 106 | # if first_last is True, we only add the last version, unless it's an "[omit]" sentinel, 107 | # in which case we also add teh last but one value which should be the last version 108 | if len(versions) > 1: 109 | add_versions = [versions[-1]] 110 | if len(versions) > 2 and add_versions[0] == OMIT_SENTINEL: 111 | add_versions.insert(0, versions[-2]) 112 | else: 113 | add_versions = versions[1:] 114 | 115 | for v in add_versions: 116 | case = [as_req(n, v if n == name else min_v) for n, min_v in min_versions] 117 | cases.append(tuple(case)) 118 | 119 | min_versions_case = tuple(as_req(n, v) for n, v in min_versions) 120 | total_cases = len(cases) + 1 121 | if max_cases and total_cases > max_cases: 122 | print(f'{total_cases} cases generated, truncating to {max_cases}') 123 | trunc_cases = set(random.sample(cases, k=max_cases - 1)) 124 | cases = [min_versions_case] + [case for case in cases if case in trunc_cases] 125 | else: 126 | print(f'{total_cases} cases generated') 127 | cases = [min_versions_case] + cases 128 | return [' '.join(case).replace(' ', ' ') for case in cases] 129 | 130 | 131 | def as_req(name: str, version: str) -> str: 132 | if version == OMIT_SENTINEL: 133 | return '' 134 | else: 135 | return f'{name}=={version}' 136 | 137 | 138 | if __name__ == '__main__': 139 | list_python_dependencies() 140 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme>=22.5.0"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.version] 6 | path = "list_python_dependencies.py" 7 | 8 | [project] 9 | name = "list_python_dependencies" 10 | description = "List all valid versions of dependency for a Python project" 11 | authors = [ 12 | {name = "Samuel Colvin", email = "s@muelcolvin.com"}, 13 | ] 14 | license = "MIT" 15 | requires-python = "~=3.11" 16 | dependencies = [ 17 | "requests==2.28.1", 18 | "packaging==22.0", 19 | ] 20 | dynamic = ["version", "readme"] 21 | 22 | [project.scripts] 23 | list_python_dependencies = "list_python_dependencies:list_python_dependencies" 24 | 25 | [tool.ruff] 26 | line-length = 120 27 | extend-select = ["Q"] 28 | flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} 29 | 30 | [tool.black] 31 | color = true 32 | line-length = 120 33 | target-version = ["py311"] 34 | skip-string-normalization = true 35 | 36 | [tool.isort] 37 | line_length = 120 38 | known_first_party = "pydantic" 39 | multi_line_output = 3 40 | include_trailing_comma = true 41 | force_grid_wrap = 0 42 | combine_as_imports = true 43 | -------------------------------------------------------------------------------- /requirements-linting.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | pyupgrade 4 | pre-commit 5 | ruff 6 | -------------------------------------------------------------------------------- /test-case/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "example" 3 | description = "Everyting else is omitted for brevity" 4 | dependencies = [ 5 | "django>=4", 6 | "pytest>=7", 7 | ] 8 | optional-dependencies = { http = ['requests>=2.20', 'black>=0.20.0'] } 9 | --------------------------------------------------------------------------------