Skip to content

Commit

Permalink
feat: add cli command 'find-build-deps'
Browse files Browse the repository at this point in the history
  • Loading branch information
bruno-fs committed Jan 30, 2023
1 parent fe63d35 commit e9fef74
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 14 deletions.
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""pytest configuration."""


def pytest_configure(config):
"""Configure pytst session."""
config.addinivalue_line("markers", "e2e: end to end tests.")
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Usage

```{eval-rst}
.. click:: pybuild_deps.__main__:main
.. click:: pybuild_deps.__main__:cli
:prog: pybuild-deps
:nested: full
```
5 changes: 3 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"xdoctest",
"docs-build",
)
test_requirements = ["pytest", "pygments", "pytest-mock"]


def activate_virtualenv_in_precommit_hooks(session: Session) -> None:
Expand Down Expand Up @@ -142,7 +143,7 @@ def safety(session: Session) -> None:
def tests(session: Session) -> None:
"""Run the test suite."""
session.install(".")
session.install("coverage[toml]", "pytest", "pygments")
session.install("coverage[toml]", *test_requirements)
try:
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
finally:
Expand All @@ -167,7 +168,7 @@ def coverage(session: Session) -> None:
def typeguard(session: Session) -> None:
"""Runtime type checking using Typeguard."""
session.install(".")
session.install("pytest", "typeguard", "pygments")
session.install("typeguard", *test_requirements)
session.run("pytest", f"--typeguard-packages={package}", *session.posargs)


Expand Down
42 changes: 36 additions & 6 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Changelog = "https://github.com/bruno-fs/pybuild-deps/releases"
python = "^3.8"
click = ">=8.0.1"
tomli = { version = "^2.0.1", python = "<3.11" }
xdg = "^5.1.1"
requests = "^2.28.2"

[tool.poetry.group.dev.dependencies]
Pygments = ">=2.10.0"
Expand All @@ -37,9 +39,10 @@ typeguard = ">=2.13.3"
xdoctest = { extras = ["colors"], version = ">=0.15.10" }
myst-parser = { version = ">=0.16.1" }
ruff = "^0.0.235"
pytest-mock = "^3.10.0"

[tool.poetry.scripts]
pybuild-deps = "pybuild_deps.__main__:main"
pybuild-deps = "pybuild_deps.__main__:cli"

[tool.coverage.paths]
source = ["src", "*/site-packages"]
Expand Down
22 changes: 19 additions & 3 deletions src/pybuild_deps/__main__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
"""Command-line interface."""
import logging

import click


@click.command()
@click.group()
@click.version_option()
def main() -> None:
@click.option("--log-level", default="ERROR")
def cli(log_level) -> None:
"""Entrypoint for PyBuild Deps."""
logging.basicConfig(level=log_level)


@cli.command()
@click.argument("package-name")
@click.argument("version")
def find_build_deps(package_name, version):
"""Find build dependencies for given package."""
from pybuild_deps.find_build_dependencies import find_build_dependencies

deps = find_build_dependencies(package_name=package_name, version=version)
for dep in deps:
click.echo(dep)


if __name__ == "__main__":
main(prog_name="pybuild-deps") # pragma: no cover
cli(prog_name="pybuild-deps") # pragma: no cover
39 changes: 39 additions & 0 deletions src/pybuild_deps/find_build_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Find build dependencies of a python package."""

import logging
import tarfile

from pybuild_deps.get_package_source import get_package_source
from pybuild_deps.parsers import parse_pyproject_toml, parse_setup_cfg, parse_setup_py


def find_build_dependencies(package_name, version):
"""Find build dependencies for a given package."""
file_parser_map = {
"pyproject.toml": parse_pyproject_toml,
"setup.cfg": parse_setup_cfg,
"setup.py": parse_setup_py,
}
logging.info("retrieving source for package %s==%s", package_name, version)
source_path = get_package_source(package_name, version)
build_dependencies = []
with tarfile.open(fileobj=source_path.open("rb")) as tarball:
for file_name, parser in file_parser_map.items():
try:
file = tarball.extractfile(f"{package_name}-{version}/{file_name}")
except KeyError:
logging.info(
"%s file not found for package %s==%s",
file_name,
package_name,
version,
)
continue
logging.info(
"parsing file %s for package %s==%s",
file_name,
package_name,
version,
)
build_dependencies += parser(file.read().decode())
return build_dependencies
55 changes: 55 additions & 0 deletions src/pybuild_deps/get_package_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Get source code for a given package."""

import logging
from pathlib import Path

import requests
from xdg import xdg_cache_home

CACHE_PATH = xdg_cache_home() / "pybuild-deps"


def get_package_source(package_name: str, version: str) -> Path:
"""Get ource code for a given package."""
cached_path = CACHE_PATH / package_name / version
tarball_path = cached_path / "source.tar.gz"
error_path = cached_path / "error.json"
if tarball_path.exists():
logging.info("using cached version for package %s==%s", package_name, version)
return tarball_path

elif error_path.exists():
raise NotImplementedError()

return retrieve_and_save_source_from_pypi(
package_name, version, tarball_path=tarball_path, error_path=error_path
)


def retrieve_and_save_source_from_pypi(
package_name: str,
version: str,
*,
tarball_path: Path,
error_path: Path,
):
"""Retrieve package source from pypi and store it in a cache."""
source_url = get_source_url(package_name, version)
response = requests.get(source_url, timeout=10)
if not response.ok:
raise NotImplementedError()
tarball_path.parent.mkdir(parents=True, exist_ok=True)
tarball_path.write_bytes(response.content)
return tarball_path


def get_source_url(package_name, version):
"""Get url for source code."""
response = requests.get(
f"https://pypi.org/pypi/{package_name}/{version}/json", timeout=10
)
if not response.ok:
raise NotImplementedError()
for url in response.json()["urls"]: # pragma: no branch
if url["python_version"] == "source":
return url["url"]
58 changes: 57 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Test cases for the __main__ module."""

from pathlib import Path

import pytest
from click.testing import CliRunner

Expand All @@ -11,7 +14,60 @@ def runner() -> CliRunner:
return CliRunner()


@pytest.fixture
def cache(monkeypatch, tmp_path):
"""Mock pybuild-deps cache."""
mocked_cache = tmp_path / "cache"
monkeypatch.setattr("pybuild_deps.get_package_source.CACHE_PATH", mocked_cache)
yield mocked_cache


def test_main_succeeds(runner: CliRunner) -> None:
"""It exits with a status code of zero."""
result = runner.invoke(__main__.main)
result = runner.invoke(__main__.cli)
assert result.exit_code == 0


def test_log_level(runner: CliRunner, mocker):
"""Test setting log level."""
patched_logconfig = mocker.patch(
"pybuild_deps.__main__.logging.basicConfig", side_effect=RuntimeError("STOP!!!")
)
result = runner.invoke(
__main__.cli, ["--log-level", "INFO", "find-build-deps", "a", "b"]
)
assert result.exit_code == 1
assert isinstance(result.exception, RuntimeError)
assert patched_logconfig.call_args == mocker.call(level="INFO")


@pytest.mark.e2e
@pytest.mark.parametrize(
"package_name,version,expected_deps",
[
("urllib3", "1.26.13", []),
(
"cryptography",
"39.0.0",
[
"setuptools>=40.6.0,!=60.9.0",
"wheel",
"cffi>=1.12; platform_python_implementation != 'PyPy'",
"setuptools-rust>=0.11.4",
],
),
],
)
def test_find_build_deps(
cache: Path, runner: CliRunner, package_name, version, expected_deps
):
"""End to end testing for find-build-deps command."""
assert not cache.exists()
result = runner.invoke(__main__.find_build_deps, args=[package_name, version])
assert result.exit_code == 0
assert result.stdout.splitlines() == expected_deps
assert cache.exists()
# repeating the same test to cover a cached version
result = runner.invoke(__main__.find_build_deps, args=[package_name, version])
assert result.exit_code == 0
assert result.stdout.splitlines() == expected_deps

0 comments on commit e9fef74

Please sign in to comment.