Source code for py_research.reflect.deps
"""Utils for reflecting the Python dependencies."""
import inspect
from types import ModuleType
from typing import Literal, TypeAlias
import importlib_metadata as meta
import numpy as np
import requests
from packaging.requirements import Requirement
from packaging.specifiers import Specifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from .dist import get_module_distribution
[docs]
def get_all_module_dependencies(
module: ModuleType,
_ext_deps: set[str] | None = None,
_int_deps: set[ModuleType] | None = None,
) -> tuple[set[str], set[ModuleType]]:
"""Return all (indirect) dependency modules of given module.
Args:
module: Module to inspect.
Returns:
Tuple of external and internal dependencies.
"""
if _ext_deps is None:
_ext_deps = set()
if _int_deps is None:
_int_deps = set()
deps = [
dep
for _, m in inspect.getmembers(module)
if (dep := inspect.getmodule(m)) is not None
]
ext_deps_map = {
dep: dist.metadata["Name"]
for dep in deps
if (dist := get_module_distribution(dep)) is not None
}
new_ext_deps = set(ext_deps_map.values()) - _ext_deps
these_int_deps = set(deps) - set(ext_deps_map.keys())
new_int_deps = these_int_deps - _int_deps
if len(new_ext_deps) > 0 or len(new_int_deps) > 0:
_ext_deps |= new_ext_deps
_int_deps |= new_int_deps
sub_ext_deps, sub_int_deps = zip(
*[
get_all_module_dependencies(d, _ext_deps, _int_deps)
for d in new_int_deps
]
)
return set.union(*sub_ext_deps), set.union(*sub_int_deps)
else:
return _ext_deps, _int_deps
[docs]
def get_dist_requirements(dist: meta.Distribution) -> list[Requirement] | None:
"""Get a list of declared packages via pdm."""
return (
[Requirement(dep) for dep in dist.requires]
if dist.requires is not None
else None
)
[docs]
def get_versions_on_pypi(package: meta.Distribution | str) -> set[Version]:
"""Get all available versions of given distribution."""
if isinstance(package, meta.Distribution):
if package.origin is not None:
return set()
package = package.name
url = f"https://pypi.org/pypi/{package}/json"
response = requests.get(url)
if response.status_code == 404:
return set()
data = response.json()
versions = set()
for v in data["releases"].keys():
try:
versions.add(Version(v))
except InvalidVersion:
pass
return versions
[docs]
def version_diff(v1: Version, v2: Version) -> Version | None:
"""Get the difference between two versions (v1 - v2).
if v1 is smaller than v2, returns None.
"""
if v1 < v2:
return None
v1_arr = np.array((v1.major, v1.minor, v1.micro))
v2_arr = np.array((v2.major, v2.minor, v2.micro))
diff = v1_arr - v2_arr
if diff[0] > 0:
diff[1:] = v1_arr[1:]
elif diff[1] > 0:
diff[2] = v1_arr[2]
return Version(".".join(str(v) for v in diff))
[docs]
def get_outdated_deps(
dist: meta.Distribution | ModuleType,
allowed_diff: Specifier = Specifier("<=1.1.1"),
) -> dict[str, tuple[Version, Version]]:
"""Get a list of outdated dependencies of a distribution.
Args:
dist:
Distribution to inspect.
Can also be supplied as a module within the distribution in question.
allowed_diff: Allowed difference between current and latest version.
Returns:
Dictionary of outdated package names with current and latest version.
"""
if isinstance(dist, ModuleType):
mod_dist = get_module_distribution(dist)
if mod_dist is None:
raise ValueError("Supplied module is not part of a distribution.")
dist = mod_dist
deps = get_dist_requirements(dist)
if deps is None:
return {}
outdated = {}
for dep in deps:
try:
dep_dist = meta.distribution(dep.name)
except meta.PackageNotFoundError:
dep_dist = dep.name
versions = get_versions_on_pypi(dep_dist)
versions = {
v
for v in versions
if not v.is_prerelease and not v.is_postrelease and not v.is_devrelease
}
matching_req = set(dep.specifier.filter(versions))
newest_matching = max(matching_req)
newer = versions - matching_req
if len(newer) == 0:
continue
newest = max(newer)
diff = version_diff(newest, newest_matching)
if diff is not None and diff not in allowed_diff:
outdated[dep.name] = (newest_matching, newest)
return outdated
VersionStrategy: TypeAlias = Literal["exact", "minor", "major"]
[docs]
def version_to_range(version: Version, strategy: VersionStrategy = "major") -> str:
"""Convert exact version to version range."""
return (
f"=={version}"
if strategy == "exact"
else f"~{version}" if strategy == "minor" else f"^{version}"
)
[docs]
def semver_range_to_spec(semver_range: str) -> SpecifierSet:
"""Convert semver range to Python version specifier."""
op = semver_range[0] if semver_range[0] in "~^>=<" else None
version = Version(semver_range.lstrip("^~>=<"))
return SpecifierSet(
(f">={version.public}" f",<{version.major + 1}")
if op == "^"
else (
(f">={version.public},<{version.major}" f".{version.minor + 1}")
if op == "~"
else f"{op}{version.public}" if op in list(">=<") else f"=={version.public}"
)
)