Skip to content

Commit e04a5cf

Browse files
committed
feat: Support checking packages from PyPI directly
1 parent 4ae5d25 commit e04a5cf

3 files changed

Lines changed: 183 additions & 42 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ dependencies = [
4040
"colorama>=0.4",
4141
]
4242

43+
[project.optional-dependencies]
44+
pypi = [
45+
"pip>=24.0",
46+
"platformdirs>=4.2",
47+
"wheel>=0.42",
48+
]
49+
4350
[project.urls]
4451
Homepage = "https://mkdocstrings.github.io/griffe"
4552
Documentation = "https://mkdocstrings.github.io/griffe"

src/griffe/_internal/cli.py

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import json
1717
import logging
1818
import os
19+
import re
1920
import sys
2021
from datetime import datetime, timezone
2122
from pathlib import Path
@@ -30,7 +31,7 @@
3031
from griffe._internal.exceptions import ExtensionError, GitError
3132
from griffe._internal.extensions.base import load_extensions
3233
from griffe._internal.git import _get_latest_tag, _get_repo_root
33-
from griffe._internal.loader import GriffeLoader, load, load_git
34+
from griffe._internal.loader import GriffeLoader, load, load_git, load_pypi
3435
from griffe._internal.logger import logger
3536

3637
if TYPE_CHECKING:
@@ -461,36 +462,19 @@ def check(
461462
search_paths.extend(sys.path)
462463

463464
against_path = against_path or package
464-
try:
465-
against = against or _get_latest_tag(package)
466-
repository = _get_repo_root(against_path)
467-
except GitError as error:
468-
print(f"griffe: error: {error}", file=sys.stderr)
469-
return 2
470-
471465
try:
472466
loaded_extensions = load_extensions(*(extensions or ()))
473467
except ExtensionError:
474468
logger.exception("Could not load extensions")
475469
return 1
476470

477-
# Load old and new version of the package.
478-
old_package = load_git(
479-
against_path,
480-
ref=against,
481-
repo=repository,
482-
extensions=loaded_extensions,
483-
search_paths=search_paths,
484-
allow_inspection=allow_inspection,
485-
force_inspection=force_inspection,
486-
resolve_aliases=True,
487-
resolve_external=None,
488-
)
489-
if base_ref:
490-
new_package = load_git(
491-
package,
492-
ref=base_ref,
493-
repo=repository,
471+
if match_against := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", against or ""):
472+
against_dist = (match_against.group(1) or str(package)).lower().replace("-", "_")
473+
against_version = match_against.group(2)
474+
old_package = load_pypi(
475+
str(package),
476+
against_dist,
477+
against_version,
494478
extensions=loaded_extensions,
495479
search_paths=search_paths,
496480
allow_inspection=allow_inspection,
@@ -499,10 +483,42 @@ def check(
499483
resolve_aliases=True,
500484
resolve_external=None,
501485
)
486+
487+
if base_ref:
488+
if not (match_base := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", base_ref)):
489+
raise ValueError(f"Base {base_ref} is not a valid dependency specifier.")
490+
base_dist = (match_base.group(1) or str(package)).lower().replace("-", "_")
491+
base_version = match_base.group(2)
492+
else:
493+
base_dist = against_dist
494+
base_version = ""
495+
new_package = load_pypi(
496+
str(package),
497+
base_dist,
498+
base_version,
499+
extensions=loaded_extensions,
500+
search_paths=search_paths,
501+
allow_inspection=allow_inspection,
502+
force_inspection=force_inspection,
503+
find_stubs_package=find_stubs_package,
504+
resolve_aliases=True,
505+
resolve_external=None,
506+
)
507+
502508
else:
503-
new_package = load(
504-
package,
505-
try_relative_path=True,
509+
against_path = against_path or package
510+
try:
511+
against = against or _get_latest_tag(package)
512+
repository = _get_repo_root(against_path)
513+
except GitError as error:
514+
print(f"griffe: error: {error}", file=sys.stderr)
515+
return 2
516+
517+
# Load old and new version of the package.
518+
old_package = load_git(
519+
against_path,
520+
ref=against,
521+
repo=repository,
506522
extensions=loaded_extensions,
507523
search_paths=search_paths,
508524
allow_inspection=allow_inspection,
@@ -512,6 +528,32 @@ def check(
512528
resolve_external=None,
513529
)
514530

531+
if base_ref:
532+
new_package = load_git(
533+
package,
534+
ref=base_ref,
535+
repo=repository,
536+
extensions=loaded_extensions,
537+
search_paths=search_paths,
538+
allow_inspection=allow_inspection,
539+
force_inspection=force_inspection,
540+
find_stubs_package=find_stubs_package,
541+
resolve_aliases=True,
542+
resolve_external=None,
543+
)
544+
else:
545+
new_package = load(
546+
package,
547+
try_relative_path=True,
548+
extensions=loaded_extensions,
549+
search_paths=search_paths,
550+
allow_inspection=allow_inspection,
551+
force_inspection=force_inspection,
552+
find_stubs_package=find_stubs_package,
553+
resolve_aliases=True,
554+
resolve_external=None,
555+
)
556+
515557
# Find and display API breakages.
516558
breakages = list(find_breaking_changes(old_package, new_package))
517559

src/griffe/_internal/loader.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
from __future__ import annotations
44

5+
import re
6+
import shutil
7+
import subprocess
8+
import sys
9+
import tempfile
510
from contextlib import suppress
611
from datetime import datetime, timezone
12+
from importlib.util import find_spec
713
from pathlib import Path
814
from typing import TYPE_CHECKING, ClassVar, cast
915

@@ -925,20 +931,23 @@ def load_git(
925931

926932

927933
def load_pypi(
928-
package: str, # noqa: ARG001
929-
distribution: str, # noqa: ARG001
930-
version_spec: str, # noqa: ARG001
934+
package: str,
935+
distribution: str,
936+
version_spec: str,
931937
*,
932-
submodules: bool = True, # noqa: ARG001
933-
extensions: Extensions | None = None, # noqa: ARG001
934-
search_paths: Sequence[str | Path] | None = None, # noqa: ARG001
935-
docstring_parser: DocstringStyle | Parser | None = None, # noqa: ARG001
936-
docstring_options: DocstringOptions | None = None, # noqa: ARG001
937-
lines_collection: LinesCollection | None = None, # noqa: ARG001
938-
modules_collection: ModulesCollection | None = None, # noqa: ARG001
939-
allow_inspection: bool = True, # noqa: ARG001
940-
force_inspection: bool = False, # noqa: ARG001
941-
find_stubs_package: bool = False, # noqa: ARG001
938+
submodules: bool = True,
939+
extensions: Extensions | None = None,
940+
search_paths: Sequence[str | Path] | None = None,
941+
docstring_parser: DocstringStyle | Parser | None = None,
942+
docstring_options: DocstringOptions | None = None,
943+
lines_collection: LinesCollection | None = None,
944+
modules_collection: ModulesCollection | None = None,
945+
allow_inspection: bool = True,
946+
force_inspection: bool = False,
947+
find_stubs_package: bool = False,
948+
resolve_aliases: bool = False,
949+
resolve_external: bool | None = None,
950+
resolve_implicit: bool = False,
942951
) -> Object | Alias:
943952
"""Load and return a module from a specific package version downloaded using pip.
944953
@@ -962,5 +971,88 @@ def load_pypi(
962971
find_stubs_package: Whether to search for stubs-only package.
963972
If both the package and its stubs are found, they'll be merged together.
964973
If only the stubs are found, they'll be used as the package itself.
974+
resolve_aliases: Whether to resolve aliases.
975+
resolve_external: Whether to try to load unspecified modules to resolve aliases.
976+
Default value (`None`) means to load external modules only if they are the private sibling
977+
or the origin module (for example when `ast` imports from `_ast`).
978+
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
965979
"""
966-
raise ValueError("Not available in non-Insiders versions of Griffe")
980+
if not all(find_spec(pkg) for pkg in ("pip", "wheel", "platformdirs")):
981+
raise RuntimeError("Please install Griffe with the 'pypi' extra to use this feature.")
982+
983+
import platformdirs # noqa: PLC0415
984+
985+
pypi_cache_dir = Path(platformdirs.user_cache_dir("griffe"))
986+
install_dir = pypi_cache_dir / f"{distribution}{version_spec}"
987+
if install_dir.exists():
988+
logger.debug("Using cached %s%s", distribution, version_spec)
989+
else:
990+
with tempfile.TemporaryDirectory(dir=pypi_cache_dir) as tmpdir:
991+
install_dir = Path(tmpdir) / distribution
992+
logger.debug("Downloading %s%s", distribution, version_spec)
993+
process = subprocess.run( # noqa: S603
994+
[
995+
sys.executable,
996+
"-mpip",
997+
"install",
998+
"--no-deps",
999+
"--no-compile",
1000+
"--no-warn-script-location",
1001+
"--no-input",
1002+
"--disable-pip-version-check",
1003+
"--no-python-version-warning",
1004+
"-t",
1005+
str(install_dir),
1006+
f"{distribution}{version_spec}",
1007+
],
1008+
text=True,
1009+
stdout=subprocess.PIPE,
1010+
stderr=subprocess.STDOUT,
1011+
check=False,
1012+
)
1013+
if process.returncode:
1014+
logger.error(process.stdout)
1015+
raise RuntimeError(f"Could not pip install {distribution}{version_spec}")
1016+
logger.debug(process.stdout)
1017+
shutil.rmtree(install_dir / "bin", ignore_errors=True)
1018+
re_dist = re.sub("[._-]", "[._-]", distribution)
1019+
version = next(
1020+
match.group(1)
1021+
for file in install_dir.iterdir()
1022+
if (match := re.match(rf"{re_dist}-(.+)\.dist-info", file.name, re.IGNORECASE))
1023+
)
1024+
dest_dir = pypi_cache_dir / f"{distribution}=={version}"
1025+
if not dest_dir.exists():
1026+
install_dir.rename(dest_dir)
1027+
install_dir = dest_dir
1028+
1029+
if not package:
1030+
files = sorted((file.name.lower() for file in install_dir.iterdir()), reverse=True)
1031+
name = distribution.lower().replace("-", "_")
1032+
if name in files or f"{name}.py" in files:
1033+
package = name
1034+
elif len(files) == 1:
1035+
raise RuntimeError(f"No package found in {distribution}=={version}")
1036+
else:
1037+
try:
1038+
package = next(file.split(".", 1)[0] for file in files if not file.endswith(".dist-info"))
1039+
except StopIteration:
1040+
raise RuntimeError(f"Could not guess package name for {distribution}=={version} (files; {files})") # noqa: B904
1041+
1042+
return load(
1043+
package,
1044+
submodules=submodules,
1045+
try_relative_path=False,
1046+
extensions=extensions,
1047+
search_paths=[install_dir, *(search_paths or ())],
1048+
docstring_parser=docstring_parser,
1049+
docstring_options=docstring_options,
1050+
lines_collection=lines_collection,
1051+
modules_collection=modules_collection,
1052+
allow_inspection=allow_inspection,
1053+
force_inspection=force_inspection,
1054+
find_stubs_package=find_stubs_package,
1055+
resolve_aliases=resolve_aliases,
1056+
resolve_external=resolve_external,
1057+
resolve_implicit=resolve_implicit,
1058+
)

0 commit comments

Comments
 (0)