File: test_loader.py

package info (click to toggle)
python-griffe 1.7.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,092 kB
  • sloc: python: 14,305; javascript: 84; makefile: 41; sh: 23
file content (491 lines) | stat: -rw-r--r-- 20,080 bytes parent folder | download
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