1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491
|
"""Tests for the `loader` module."""
from __future__ import annotations
import logging
from textwrap import dedent
from typing import TYPE_CHECKING
import pytest
from griffe import (
ExprName,
GriffeLoader,
temporary_inspected_package,
temporary_pyfile,
temporary_pypackage,
temporary_visited_package,
)
if TYPE_CHECKING:
from pathlib import Path
from griffe import Alias
def test_has_docstrings_does_not_try_to_resolve_alias() -> None:
"""Assert that checkins presence of docstrings does not trigger alias resolution."""
with temporary_pyfile("""from abc import abstractmethod""") as (module_name, path):
loader = GriffeLoader(search_paths=[path.parent])
module = loader.load(module_name)
loader.resolve_aliases()
assert "abstractmethod" in module.members
assert not module.has_docstrings
def test_recursive_wildcard_expansion() -> None:
"""Assert that wildcards are expanded recursively."""
with temporary_pypackage("package", ["mod_a/mod_b/mod_c.py"]) as tmp_package:
mod_a_dir = tmp_package.path / "mod_a"
mod_b_dir = mod_a_dir / "mod_b"
mod_a = mod_a_dir / "__init__.py"
mod_b = mod_b_dir / "__init__.py"
mod_c = mod_b_dir / "mod_c.py"
mod_c.write_text("CONST_X = 'X'\nCONST_Y = 'Y'")
mod_b.write_text("from .mod_c import *")
mod_a.write_text("from .mod_b import *")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert "CONST_X" in package["mod_a.mod_b.mod_c"].members
assert "CONST_Y" in package["mod_a.mod_b.mod_c"].members
assert "CONST_X" not in package.members
assert "CONST_Y" not in package.members
loader.expand_wildcards(package) # type: ignore[arg-type]
assert "CONST_X" in package["mod_a"].members
assert "CONST_Y" in package["mod_a"].members
assert "CONST_X" in package["mod_a.mod_b"].members
assert "CONST_Y" in package["mod_a.mod_b"].members
def test_dont_shortcut_alias_chain_after_expanding_wildcards() -> None:
"""Assert public aliases paths are not resolved to canonical paths when expanding wildcards."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py", "mod_c.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_c = tmp_package.path / "mod_c.py"
mod_a.write_text("from package.mod_b import *\nclass Child(Base): ...\n")
mod_b.write_text("from package.mod_c import Base\n__all__ = ['Base']\n")
mod_c.write_text("class Base: ...\n")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
child = package["mod_a.Child"]
assert child.bases
base = child.bases[0]
assert isinstance(base, ExprName)
assert base.name == "Base"
assert base.canonical_path == "package.mod_b.Base"
def test_dont_overwrite_lower_member_when_expanding_wildcard() -> None:
"""Check that we don't overwrite a member defined after the import when expanding a wildcard."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_a.write_text("overwritten = 0\nfrom package.mod_b import *\nnot_overwritten = 0\n")
mod_b.write_text("overwritten = 1\nnot_overwritten = 1\n")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert package["mod_a.overwritten"].value == "1"
assert package["mod_a.not_overwritten"].value == "0"
def test_load_data_from_stubs() -> None:
"""Check that the loader is able to load data from stubs / `*.pyi` files."""
with temporary_pypackage("package", ["_rust_notify.pyi"]) as tmp_package:
# Code taken from samuelcolvin/watchfiles project.
code = '''
from typing import List, Literal, Optional, Protocol, Set, Tuple, Union
__all__ = ['RustNotify']
class AbstractEvent(Protocol):
def is_set(self) -> bool: ...
class RustNotify:
"""
Interface to the Rust [notify](https://crates.io/crates/notify) crate which does
the heavy lifting of watching for file changes and grouping them into a single event.
"""
def __init__(self, watch_paths: List[str], debug: bool) -> None:
"""
Create a new RustNotify instance and start a thread to watch for changes.
`FileNotFoundError` is raised if one of the paths does not exist.
Args:
watch_paths: file system paths to watch for changes, can be directories or files
debug: if true, print details about all events to stderr
"""
'''
tmp_package.path.joinpath("_rust_notify.pyi").write_text(dedent(code))
tmp_package.path.joinpath("__init__.py").write_text(
"from ._rust_notify import RustNotify\n__all__ = ['RustNotify']",
)
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert "_rust_notify" in package.members
assert "RustNotify" in package.members
assert package["RustNotify"].resolved
def test_load_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge data loaded from `*.py` and `*.pyi` files."""
with temporary_pypackage("package", ["mod.py", "mod.pyi"]) as tmp_package:
tmp_package.path.joinpath("mod.py").write_text(
dedent(
"""
CONST = 0
class Class:
class_attr = True
def function1(self, arg1):
pass
def function2(self, arg1=2.2):
pass
""",
),
)
tmp_package.path.joinpath("mod.pyi").write_text(
dedent(
"""
from typing import Sequence, overload
CONST: int
class Class:
class_attr: bool
@overload
def function1(self, arg1: str) -> Sequence[str]: ...
@overload
def function1(self, arg1: bytes) -> Sequence[bytes]: ...
def function2(self, arg1: float) -> float: ...
""",
),
)
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert "mod" in package.members
mod = package["mod"]
assert mod.filepath.suffix == ".py"
assert "CONST" in mod.members
const = mod["CONST"]
assert const.value == "0"
assert const.annotation.name == "int"
assert "Class" in mod.members
class_ = mod["Class"]
assert "class_attr" in class_.members
class_attr = class_["class_attr"]
assert class_attr.value == "True"
assert class_attr.annotation.name == "bool"
assert "function1" in class_.members
function1 = class_["function1"]
assert len(function1.overloads) == 2
assert "function2" in class_.members
function2 = class_["function2"]
assert function2.returns.name == "float"
assert function2.parameters["arg1"].annotation.name == "float"
assert function2.parameters["arg1"].default == "2.2"
def test_overwrite_module_with_attribute() -> None:
"""Check we are able to overwrite a module with an attribute."""
with temporary_pypackage("package", ["mod.py"]) as tmp_package:
tmp_package.path.joinpath("mod.py").write_text("mod: list = [0, 1, 2]")
tmp_package.path.joinpath("__init__.py").write_text("from package.mod import *")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
loader.load(tmp_package.name)
loader.resolve_aliases()
def test_load_package_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge a package loaded from `*.py` and `*.pyi` files.
This is a special case of the previous test: where the package itself has a top level
`__init__.pyi` (not so uncommon).
"""
with temporary_pypackage("package", ["__init__.py", "__init__.pyi"]) as tmp_package:
tmp_package.path.joinpath("__init__.py").write_text("globals()['f'] = lambda x: str(x)")
tmp_package.path.joinpath("__init__.pyi").write_text("def f(x: int) -> str: ...")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert "f" in package.members
def test_load_single_module_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge a single-module package loaded from `*.py` and `*.pyi` files.
This is a special case of the previous test: where the package is a single module
distribution that also drops a `.pyi` file in site-packages.
"""
with temporary_pypackage("just_a_folder", ["mod.py", "mod.pyi"]) as tmp_folder:
tmp_folder.path.joinpath("__init__.py").unlink()
tmp_folder.path.joinpath("mod.py").write_text("globals()['f'] = lambda x: str(x)")
tmp_folder.path.joinpath("mod.pyi").write_text("def f(x: int) -> str: ...")
loader = GriffeLoader(search_paths=[tmp_folder.path])
package = loader.load("mod")
assert "f" in package.members
def test_unsupported_item_in_all(caplog: pytest.LogCaptureFixture) -> None:
"""Check that unsupported items in `__all__` log a warning.
Parameters:
caplog: Pytest fixture to capture logs.
"""
item_name = "XXX"
with temporary_pypackage("package", ["mod.py"]) as tmp_folder:
tmp_folder.path.joinpath("__init__.py").write_text(f"from .mod import {item_name}\n__all__ = [{item_name}]")
tmp_folder.path.joinpath("mod.py").write_text(f"class {item_name}: ...")
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
loader.expand_exports(loader.load("package")) # type: ignore[arg-type]
assert any(item_name in record.message and record.levelname == "WARNING" for record in caplog.records)
def test_skip_modules_with_dots_in_filename(caplog: pytest.LogCaptureFixture) -> None:
"""Check that modules with dots in their filenames are skipped.
Parameters:
caplog: Pytest fixture to capture logs.
"""
caplog.set_level(logging.DEBUG)
with temporary_pypackage("package", ["gunicorn.conf.py"]) as tmp_folder:
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
loader.load("package")
assert any("gunicorn.conf.py" in record.message and record.levelname == "DEBUG" for record in caplog.records)
def test_nested_namespace_packages() -> None:
"""Load a deeply nested namespace package."""
with temporary_pypackage("a/b/c/d", ["mod.py"]) as tmp_folder:
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
a_package = loader.load("a")
assert "b" in a_package.members
b_package = a_package.members["b"]
assert "c" in b_package.members
c_package = b_package.members["c"]
assert "d" in c_package.members
d_package = c_package.members["d"]
assert "mod" in d_package.members
def test_multiple_nested_namespace_packages() -> None:
"""Load a deeply nested namespace package appearing in several places."""
with temporary_pypackage("a/b/c/d", ["mod1.py"], init=False) as tmp_ns1: # noqa: SIM117
with temporary_pypackage("a/b/c/d", ["mod2.py"], init=False) as tmp_ns2:
with temporary_pypackage("a/b/c/d", ["mod3.py"], init=False) as tmp_ns3:
tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2, tmp_ns3)]
loader = GriffeLoader(search_paths=tmp_namespace_pkgs)
a_package = loader.load("a")
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a") in a_package.filepath # type: ignore[operator]
assert "b" in a_package.members
b_package = a_package.members["b"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b") in b_package.filepath # type: ignore[operator]
assert "c" in b_package.members
c_package = b_package.members["c"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b/c") in c_package.filepath # type: ignore[operator]
assert "d" in c_package.members
d_package = c_package.members["d"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b/c/d") in d_package.filepath # type: ignore[operator]
assert "mod1" in d_package.members
assert "mod2" in d_package.members
assert "mod3" in d_package.members
def test_stop_at_first_package_inside_namespace_package() -> None:
"""Stop loading similar paths once we found a non-namespace package."""
with temporary_pypackage("a/b/c/d", ["mod1.py"], init=True) as tmp_ns1: # noqa: SIM117
with temporary_pypackage("a/b/c/d", ["mod2.py"], init=True) as tmp_ns2:
tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2)]
loader = GriffeLoader(search_paths=tmp_namespace_pkgs)
a_package = loader.load("a")
assert "b" in a_package.members
b_package = a_package.members["b"]
assert "c" in b_package.members
c_package = b_package.members["c"]
assert "d" in c_package.members
d_package = c_package.members["d"]
assert d_package.is_subpackage
assert d_package.filepath == tmp_ns1.tmpdir.joinpath("a/b/c/d/__init__.py")
assert "mod1" in d_package.members
assert "mod2" not in d_package.members
def test_load_builtin_modules() -> None:
"""Assert builtin/compiled modules can be loaded."""
loader = GriffeLoader()
loader.load("_ast")
loader.load("_collections")
loader.load("_operator")
assert "_ast" in loader.modules_collection
assert "_collections" in loader.modules_collection
assert "_operator" in loader.modules_collection
def test_resolve_aliases_of_builtin_modules() -> None:
"""Assert builtin/compiled modules can be loaded."""
loader = GriffeLoader()
loader.load("io")
loader.load("_io")
unresolved, _ = loader.resolve_aliases(external=True, implicit=True, max_iterations=1)
io_unresolved = {un for un in unresolved if un.startswith(("io", "_io"))}
assert len(io_unresolved) < 5
@pytest.mark.parametrize("namespace", [False, True])
def test_loading_stubs_only_packages(tmp_path: Path, namespace: bool) -> None:
"""Test loading and merging of stubs-only packages.
Parameters:
tmp_path: Pytest fixture.
namespace: Whether the package and stubs are namespace packages.
"""
# Create package.
package_parent = tmp_path / "pkg_parent"
package_parent.mkdir()
package = package_parent / "package"
package.mkdir()
if not namespace:
package.joinpath("__init__.py").write_text("a: int = 0")
package.joinpath("module.py").write_text("a: int = 0")
# Create stubs.
stubs_parent = tmp_path / "stubs_parent"
stubs_parent.mkdir()
stubs = stubs_parent / "package-stubs"
stubs.mkdir()
if not namespace:
stubs.joinpath("__init__.pyi").write_text("b: int")
stubs.joinpath("module.pyi").write_text("b: int")
# Exposing stubs first, to make sure order doesn't matter.
loader = GriffeLoader(search_paths=[stubs_parent, package_parent])
# Loading package and stubs, checking their contents.
top_module = loader.load("package", try_relative_path=False, find_stubs_package=True)
if not namespace:
assert "a" in top_module.members
assert "b" in top_module.members
assert "a" in top_module["module"].members
assert "b" in top_module["module"].members
@pytest.mark.parametrize(
"init",
[
"from package.thing import thing",
"thing = False",
],
)
def test_submodule_shadowing_member(init: str, caplog: pytest.LogCaptureFixture) -> None:
"""Warn when a submodule shadows a member of the same name.
Parameters:
init: Contents of the top-level init module.
"""
caplog.set_level(logging.DEBUG)
with temporary_visited_package(
"package",
{"__init__.py": init, "thing.py": "thing = True"},
init=True,
):
assert "shadowing" in caplog.text
@pytest.mark.parametrize("wildcard", [True, False])
@pytest.mark.parametrize(("external", "foo_is_resolved"), [(None, True), (True, True), (False, False)])
def test_side_loading_sibling_private_module(wildcard: bool, external: bool | None, foo_is_resolved: bool) -> None:
"""Automatically load `_a` when `a` (wildcard) imports from it.
Parameters:
wildcard: Whether the import is a wildcard import.
external: Value for the `external` parameter when resolving aliases.
foo_is_resolved: Whether the `foo` alias should be resolved.
"""
with temporary_pypackage("_a", {"__init__.py": "def foo():\n '''Docstring.'''"}) as pkg_a: # noqa: SIM117
with temporary_pypackage("a", {"__init__.py": f"from _a import {'*' if wildcard else 'foo'}"}) as pkg_a_private:
loader = GriffeLoader(search_paths=[pkg_a.tmpdir, pkg_a_private.tmpdir])
package = loader.load("a")
loader.resolve_aliases(external=external, implicit=True)
if foo_is_resolved:
assert "foo" in package.members
assert package["foo"].is_alias
assert package["foo"].resolved
assert package["foo"].docstring.value == "Docstring."
elif wildcard:
assert "foo" not in package.members
else:
assert "foo" in package.members
assert package["foo"].is_alias
assert not package["foo"].resolved
def test_forcing_inspection() -> None:
"""Load a package with forced dynamic analysis."""
modules = {"__init__.py": "a = 0", "mod.py": "b = 1"}
with (
temporary_visited_package("static_pkg", modules) as static_package,
temporary_inspected_package("dynamic_pkg", modules) as dynamic_package,
):
for name in static_package.members:
assert name in dynamic_package.members
for name in static_package["mod"].members:
assert name in dynamic_package["mod"].members
def test_relying_on_modules_path_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
"""Load a package that relies on the `__path__` attribute of a module."""
def raise_module_not_found_error(*args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003
raise ModuleNotFoundError
loader = GriffeLoader()
monkeypatch.setattr(loader.finder, "find_spec", raise_module_not_found_error)
assert loader.load("griffe")
def test_not_calling_package_loaded_hook_on_something_else_than_package() -> None:
"""Always call the `on_package_loaded` hook on a package, not any other object."""
with temporary_pypackage("pkg", {"__init__.py": "from typing import List as L"}) as pkg:
loader = GriffeLoader(search_paths=[pkg.tmpdir])
alias: Alias = loader.load("pkg.L") # type: ignore[assignment]
assert alias.is_alias
assert not alias.resolved
|