From dea4c830e3bfa0bf7c9f307975cb53e1314c50eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Wed, 12 Jul 2023 11:54:00 +0200 Subject: [PATCH] feat: Rework extension system Before, extensions were sub-visitors or sub-inspectors. It meant that to support both static and dynamic analysis, extension writers were forced to write two extensions, one of each type. Before, extensions ran wholy at particular points in time. It meant that it was not possible to run some logic just after a class was instantiated (and before its members were loaded), while running some other logic after everything (instance + members loaded if any). Some logic could also never be ran, because of the way the top visitor/inspector does not enter into every node. Now, extensions are generic and can handle both static and dynamic analysis. Now, extensions use hooks on events and are not limited by the visit/inspection of the top agent. Visitor and inspector extensions are deprecated. --- docs/extensions.md | 617 ++++++++++++++++++++++++---- mkdocs.yml | 6 +- src/griffe/__init__.py | 22 +- src/griffe/agents/inspector.py | 28 +- src/griffe/agents/nodes/_runtime.py | 13 + src/griffe/agents/visitor.py | 27 +- src/griffe/cli.py | 15 +- src/griffe/extensions/__init__.py | 2 + src/griffe/extensions/base.py | 242 ++++++++++- tests/test_extensions.py | 132 ++++++ 10 files changed, 1004 insertions(+), 100 deletions(-) create mode 100644 tests/test_extensions.py diff --git a/docs/extensions.md b/docs/extensions.md index b325b2a4..033d90ba 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1,96 +1,573 @@ # Extensions -You can pass extensions to the loader to augment its capacities: +Extensions allow to enhance or customize the data that Griffe collects. + +## Using extensions + +Extensions can be specified both on the command-line +(in the terminal), and programmatically (in Python). + +### On the command-line + +On the command-line, you can specify extensions to use +with the `-e`, `--extensions` option. This option +accepts a single positional argument which can take +two forms: + +- a comma-separated list of extensions +- a JSON list of extensions + +Extensions can accept options: the comma-separated list +does not allow to specify options, while the JSON list does. +See examples below. + +With both forms, each extension refers to one of these three things: + +- the name of a built-in extension's module, for example `dynamic_docstrings` + (this is just an example, this built-in extension does not exist) +- the Python dotted-path to a module containing one or more extensions, + or to an extension directly, for example `package.module` and `package.module.ThisExtension` +- the file path to a Python script, and an optional extension name, separated by a colon, + for example `scripts/griffe_exts.py` and `scripts/griffe_exts.py:ThisExtension` + +The specified extension modules can contain more than one extension: +Griffe will pick up and load every extension declared or imported within the modules. +If options are specified for a module that contains +multiple extensions, the same options will be passed to all the extensions, +so extension writers must make sure that all extensions within +a single module accept the same options. +If they don't, Griffe will abort with an error. + +To specify options in the JSON form, use a dictionary instead of a string: +the dictionary's only key is the extension identifier (built-in name, Python path, file path) +and its value is a dictionary of options. + +Some examples: + +```bash +griffe dump griffe -e pydantic,scripts/exts.py:DynamicDocstrings,griffe_attrs +``` + +```bash +griffe check --search src griffe -e '[ + {"pydantic": {"schema": true}}, + { + "scripts/exts.py:DynamicDoctrings": { + "paths": ["mypkg.mymod.myobj"] + } + }, + "griffe_attrs" +]' +``` + +In the above two examples, `pydantic` would be a built-in extension, +`scripts/exts.py:DynamicDocstrings` the file path plus name of a local extension, +and `griffe_attrs` the name of a third-party package that exposes +one or more extensions. + +### Programmatically + +Within Python code, extensions can be specified +with the `extensions` parameter of the [`GriffeLoader` class][griffe.loader.GriffeLoader] +or [`load` function][griffe.loader.load]. + +The parameter accepts an instance of the [`Extensions` class][griffe.extensions.Extensions]. +Such an instance is created with the help of the [`load_extensions` function][griffe.extensions.load_extensions], +which itself accepts a list of strings, dictionaries, extension classes and extension instances. + +Strings and dictionaries are used the same way as [on the command-line](#on-the-command-line). +Extension instances are used as such, and extension classes are instantiated +without any options. + +Example: ```python -from griffe.loader import GriffeLoader -from griffe.extensions import VisitorExtension, Extensions, When +import griffe + +from mypackage.extensions import ThisExtension, ThisOtherExtension + +extensions = griffe.load_extensions( + [ + {"pydantic": {"schema": true}}, + {"scripts/exts.py:DynamicDoctrings": {"paths": ["mypkg.mymod.myobj"]}}, + "griffe_attrs", + ThisExtension(option="value"), + ThisOtherExtension, + ] +) + +data = griffe.load("mypackage", extensions=extensions) +``` + +### In MkDocs + +MkDocs and its mkdocstrings plugin can be configured to use Griffe extensions: + +```yaml title="mkdocs.yml" +plugins: +- mkdocstrings: + handlers: + python: + options: + extensions: + - pydantic: {schema: true} + - scripts/exts.py:DynamicDoctrings: + paths: [mypkg.mymod.myobj] + - griffe_attrs +``` + +The `extensions` key accepts a list that is passed to the +[`load_extensions` function][griffe.extensions.load_extensions]. +See [how to use extensions programmatically](#programmatically) to learn more. + +## Writing extensions + +In the next section we give a bit of context on how Griffe works, +to show how extensions can integrate into the data collection process. +Feel free to skip to the [Events and hooks](#events-and-hooks) section +or the [Full example](#full-example) section +if you'd prefer to see concrete examples first. + +### How it works + +To extract information from your Python sources, +Griffe tries to build Abstract Syntax Trees by parsing the sources +with [`ast`][] utilities. + +If the source code is not available +(the modules are built-in or compiled), +Griffe imports the modules and builds object trees instead. -# import extensions -from some.package import TheirExtension +Griffe then follows the [Visitor pattern](https://www.wikiwand.com/en/Visitor_pattern) +to walk the tree and extract information. +For ASTs, Griffe uses its [Visitor agent][griffe.agents.visitor] +and for object trees, it uses its [Inspector agent][griffe.agents.inspector]. +Sometimes during the walk through the tree (depth-first order), +both the visitor and inspector agents will trigger events. +These events can be hooked on by extensions to alter or enhance +Griffe's behavior. Some hooks will be passed just the current +node being visited, others will be passed both the node +and an instance of an [Object][griffe.dataclasses.Object] subclass, +such as a [Module][griffe.dataclasses.Module], +a [Class][griffe.dataclasses.Class], +a [Function][griffe.dataclasses.Function], +or an [Attribute][griffe.dataclasses.Attribute]. +Extensions will therefore be able to modify these instances. -# or define your own -class ClassStartsAtOddLineNumberExtension(VisitorExtension): - when = When.after_all +The following flow chart shows an example of an AST visit. +The tree is simplified: actual trees have a lot more nodes +like `if/elif/else` nodes, `try/except/else/finally` nodes, +[and many more][ast.AST]. - def visit_classdef(self, node) -> None: - if node.lineno % 2 == 1: - self.visitor.current.labels.add("starts at odd line number") +```mermaid +flowchart TB +M(Module definition) --- C(Class definition) & F(Function definition) +C --- m(Function definition) & A(Variable assignment) +``` + +The following flow chart shows an example of an object tree inspection. +The tree is simplified as well: +[many more types of objects are handled][griffe.agents.nodes.ObjectKind]. + +```mermaid +flowchart TB +M(Module) --- C(Class) & F(Function) +C --- m(Method) & A(Attribute) +``` + +For a more concrete example, let say that we visit (or inspect) +an AST (or object tree) for a given module, and that this module +contains a single class, which itself contains a single method: + +- the agent (visitor or inspector) will walk through the tree + by starting with the module node +- it will instantiate a [Module][griffe.dataclasses.Module], + then walk through its members, continuing with the class node +- it will instantiate a [Class][griffe.dataclasses.Class], + then walk through its members, continuing with the function node +- it will instantiate a [Function][griffe.dataclasses.Function] +- then it will go back up and finish walking since there are + no more nodes to walk through + + +Every time the agent enters a node, +creates an object instance, +or finish handling members of an object, +it will trigger an event. + +The flow of events is drawn in the following flowchart: +```mermaid +flowchart TB +visit_mod{{enter module node}} +event_mod_node{{"on_node event
on_module_node event"}} +create_mod{{create module instance}} +event_mod_instance{{"on_instance event
on_module_instance event"}} +visit_mod_members{{visit module members}} +visit_cls{{enter class node}} +event_cls_node{{"on_node event
on_class_node event"}} +create_cls{{create class instance}} +event_cls_instance{{"on_instance event
on_class_instance event"}} +visit_cls_members{{visit class members}} +visit_func{{enter func node}} +event_func_node{{"on_node event
on_function_node event"}} +create_func{{create function instance}} +event_func_instance{{"on_instance event
on_function_instance event"}} +event_cls_members{{"on_members event
on_class_members event"}} +event_mod_members{{"on_members event
on_module_members event"}} -extensions = Extensions(TheirExtension, ClassStartsAtOddLineNumberExtension) -griffe = GriffeLoader(extensions=extensions) -fastapi = griffe.load_module("fastapi") +start{start} --> visit_mod +visit_mod --> event_mod_node +event_mod_node --> create_mod +create_mod --> event_mod_instance +event_mod_instance --> visit_mod_members +visit_mod_members --1--> visit_cls +visit_cls --> event_cls_node +event_cls_node --> create_cls +create_cls --> event_cls_instance +event_cls_instance --> visit_cls_members +visit_cls_members --1--> visit_func +visit_func --> event_func_node +event_func_node --> create_func +create_func --> event_func_instance +event_func_instance --> visit_cls_members +visit_cls_members --2--> event_cls_members +event_cls_members --> visit_mod_members +visit_mod_members --2--> event_mod_members +event_mod_members --> finish{finish} + +class event_mod_node event +class event_mod_instance event +class event_cls_node event +class event_cls_instance event +class event_func_node event +class event_func_instance event +class event_cls_members event +class event_mod_members event +classDef event stroke:#3cc,stroke-width:2 ``` -Extensions are subclasses of a custom version of [`ast.NodeVisitor`][ast.NodeVisitor]. -Griffe uses a node visitor as well, that we will call the *main visitor*. -The extensions are instantiated with a reference to this main visitor, -so they can benefit from its capacities (navigating the nodes, getting the current -class or module, etc.). +Hopefully this flowchart gave you a pretty good idea +of what happens when Griffe collects data from a Python module. +The next setion will explain in more details +the different events that are triggered, +and how to hook onto them in your extensions. + +### Events and hooks + +There are 3 generic **events**: + +- [`on_node`][griffe.extensions.base.Extension.on_node] +- [`on_instance`][griffe.extensions.base.Extension.on_instance] +- [`on_members`][griffe.extensions.base.Extension.on_members] + +There are also specific **events** for each object kind: -Each time a node is visited, the main visitor will make the extensions visit the node as well. -Implement the `visit_` methods to visit nodes of certain types, -and act on their properties. See the [full list of AST nodes](#ast-nodes). +- [`on_module_node`][griffe.extensions.base.Extension.on_module_node] +- [`on_module_instance`][griffe.extensions.base.Extension.on_module_instance] +- [`on_module_members`][griffe.extensions.base.Extension.on_module_members] +- [`on_class_node`][griffe.extensions.base.Extension.on_class_node] +- [`on_class_instance`][griffe.extensions.base.Extension.on_class_instance] +- [`on_class_members`][griffe.extensions.base.Extension.on_class_members] +- [`on_function_node`][griffe.extensions.base.Extension.on_function_node] +- [`on_function_instance`][griffe.extensions.base.Extension.on_function_instance] +- [`on_attribute_node`][griffe.extensions.base.Extension.on_attribute_node] +- [`on_attribute_instance`][griffe.extensions.base.Extension.on_attribute_instance] -!!! warning "Important note" - Because the main visitor recursively walks the tree itself, - calling extensions on each node, - **you must not visit child nodes in your `.visit_*` methods!** - Otherwise, nodes down the tree will be visited twice or more: - once by the main visitor, and as many times more as your extension is called. - Let the main visitor do the walking, and just take care of the current node, - without handling its children. +The "on node" events are triggered when the agent (visitor or inspector) +starts handling a node in the tree (AST or object tree). -You can access the main visitor state and data through the `.visitor` attribute, -and the nodes instances are extended with additional attributes and properties: +The "on instance" events are triggered when the agent +just created an instance of [Module][griffe.dataclasses.Module], +[Class][griffe.dataclasses.Class], +[Function][griffe.dataclasses.Function], +or [Attribute][griffe.dataclasses.Attribute], +and added it as a member of its parent. + +The "on members" events are triggered when the agent +just finished handling all the members of an object. +Functions and attributes do not have members, +so there are no "on members" event for these two kinds. + +**Hooks** are methods that are called when a particular +event is triggered. To target a specific event, +the hook must be named after it. + +**Extensions** are classes that inherit from +[Griffe's Extension base class][griffe.extensions.Extension] +and define some hooks as methods: ```python +import ast +from griffe import Extension, Object, ObjectNode + + class MyExtension(Extension): - def visit_functiondef(self, node) -> None: - node.parent # the parent node - node.children # the list of children nodes - node.siblings # all the siblings nodes, from top to bottom - node.previous_siblings # only the previous siblings, from closest to top - node.next_siblings # only the next siblings, from closest to bottom - node.previous # first previous sibling - node.next # first next sibling - - self.visitor # the main visitor - self.visitor.current # the current data object - self.visitor.current.kind # the kind of object: module, class, function, attribute -``` - -See the data classes ([`Module`][griffe.dataclasses.Module], -[`Class`][griffe.dataclasses.Class], [`Function`][griffe.dataclasses.Function] -and [`Attribute`][griffe.dataclasses.Attribute]) -for a complete description of their methods and attributes. - -Extensions are run at certain moments while walking the Abstract Syntax Tree (AST): - -- before the visit/inspection: `When.before_all`. - The current node has been grafted to its parent. - If this node represents a data object, the object (`self.visitor.current`/`self.inspector.current`) **is not** yet instantiated. -- before the children visit/inspection: `When.before_children`. - If this node represents a data object, the object (`self.visitor.current`/`self.inspector.current`) **is** now instantiated. - Children **have not** yet been visited/inspected. -- after the children visit/inspection: `When.after_children`. - Children **have** now been visited/inspected. -- after the visit/inspection: `When.after_all` - -See [the `When` enumeration][griffe.extensions.When]. - -To tell the main visitor to run your extension at a certain time, -set its `when` attribute: + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + """Do something with `node` and/or `obj`.""" +``` + +Hooks are always defined as methods of a class inheriting from +[Extension][griffe.extensions.Extension], never as standalone functions. + +Since hooks are declared in a class, feel free to also declare state variables +(or any other variable) in the `__init__` method: ```python +import ast +from griffe import Extension, Object, ObjectNode + + class MyExtension(Extension): - when = When.after_children + def __init__(self) -> None: + super().__init__(self) + self.state_thingy = "initial stuff" + self.list_of_things = [] + + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + """Do something with `node` and/or `obj`.""" ``` -By default, it will run the extension after the visit/inspection of the node: -that's when the full data for this node and its children is loaded. +### Static/dynamic support + +Extensions can support both static and dynamic analysis of modules. +If a module is scanned statically, your extension hooks +will receive AST nodes (from the [ast][] module of the standard library). +If the module is scanned dynamically, +your extension hooks will receive [object nodes][griffe.ObjectNode]. + +To support static analysis, dynamic analysis, or both, +you can therefore check the type of the received node: + +```python +import ast +from griffe import Extension, Object, ObjectNode + + +class MyExtension(Extension): + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + """Do something with `node` and/or `obj`.""" + if isinstance(node, ast.AST): + ... # apply logic for static analysis + else: + ... # apply logic for dynamic analysis +``` + +Since hooks also receive instantiated modules, classes, functions and attributes, +most of the time you will not need to use the `node` argument +other than for checking its type and deciding what to do based on the result. +If you do need to, read the next section explaining how to visit trees. + +### Visiting trees + +Extensions provide basic functionality to help you visit trees: + +- [`visit`][griffe.extensions.base.Extension.visit]: call `self.visit(node)` + to start visiting an abstract syntax tree. +- [`generic_visit`][griffe.extensions.base.Extension.generic_visit]: call + `self.generic_visit(node)` to visit each subnode of a given node. +- [`inspect`][griffe.extensions.base.Extension.inspect]: call `self.inspect(node)` + to start visiting an object tree. Nodes contain references to the runtime objects, + see [`ObjectNode`][griffe.agents.nodes.ObjectNode]. +- [`generic_inspect`][griffe.extensions.base.Extension.generic_inspect]: call + `self.generic_inspect(node)` to visit each subnode of a given node. + +Calling `self.visit(node)` or `self.inspect(node)` will do nothing +unless you actually implement methods that handle specific types of nodes: + +- for ASTs, methods must be named `visit_` where `` + is replaced with the lowercase name of the node's class. For example, + to allow visiting [`ClassDef`][ast.ClassDef] nodes, you must + implement the `visit_classdef` method: + + ```python + import ast + from griffe import Extension + + + class MyExtension(Extension): + def visit_classdef(node: ast.ClassDef) -> None: + # do something with the node + ... + # then visit the subnodes + # (it only makes sense if you implement other methods + # such as visit_functiondef or visit_assign for example) + self.generic_visit(node) + ``` + + See the [list of existing AST classes](#ast-nodes) to learn + what method you can implement. + +- for object trees, methods must be named `inspect_`, + where `` is replaced with the string value of the node's kind. + The different kinds are listed in the [`ObjectKind`][griffe.agents.nodes.ObjectKind] enumeration. + For example, to allow inspecting coroutine nodes, you must implement + the `inspect_coroutine` method: + + ```python + from griffe import Extension, ObjectNode + + + class MyExtension(Extension): + def inspect_coroutine(node: ObjectNode) -> None: + # do something with the node + ... + # then visit the subnodes if it makes sense + self.generic_inspect(node) + ``` + +### Extra data + +All Griffe objects (modules, classes, functions, attributes) +can store additional (meta)data in their `extra` attribute. +This attribute is a dictionary of dictionaries. The first +layer is used as namespacing: each extension writes +into its own namespace, or integrates with other projects +by reading/writing in their namespaces, +according to what they support and document. + +```python +import ast +from griffe import Extension, Object, ObjectNode + +self_namespace = "my_extension" + + +class MyExtension(Extension): + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + obj.extra[self_namespace]["some_key"] = "some_value" +``` + +For example, [mkdocstrings-python](https://mkdocstrings.github.io/python) +looks into the `mkdocstrings` namespace for a `template` key. +Extensions can therefore provide a custom template value by writing +into `extra["mkdocstrings"]["template"]`: + +```python +import ast +from griffe import Extension, ObjectNode, Class + +self_namespace = "my_extension" +mkdocstrings_namespace = "mkdocstrings" + + +class MyExtension(Extension): + def on_class_instance(self, node: ast.AST | ObjectNode, cls: Class) -> None: + obj.extra[mkdocstrings_namespace]["template"] = "my_custom_template" +``` + +[Read more about mkdocstrings handler extensions.](https://mkdocstrings.github.io/usage/handlers/#handler-extensions) + +### Options + +Extensions can be made to support options. +These options can then be passed from the [command-line](#on-the-command-line) using JSON, +from Python directly, or from other tools like MkDocs, in `mkdocs.yml`. + +```python +import ast +from griffe import Attribute, Extension, ObjectNode + + +class MyExtension(Extension): + def __init__(self, option1: str, option2: bool = False) -> None: + super().__init__(self) + self.option1 = option1 + self.option2 = option2 + + def on_attribute_instance(self, node: ast.AST | ObjectNode, attr: Attribute) -> None: + if self.option2: + ... # do something +``` + +### Logging + +To better integrate with Griffe and other tools in the ecosystem +(notably MkDocs), use Griffe loggers to log messages: + +```python +import ast +from griffe import Extension, ObjectNode, Module, get_logger + +logger = get_logger(__name__) + + +class MyExtension(Extension): + def on_module_members(self, node: ast.AST | ObjectNode, mod: Module) -> None: + logger.info(f"Doing some work on module {mod.path} and its members") +``` + +### Full example + +The following example shows how one could write a "dynamic docstrings" extension +that dynamically import objects that declare their docstrings dynamically, +to improve support for such docstrings. +The extension is configurable to run only on user-selected objects. + +Package structure (or just write your extension in a local script): + +```tree +./ + pyproject.toml + src/ + dynamic_docstrings/ + __init__.py + extension.py +``` + + +```python title="./src/dynamic_docstrings/extension.py" +import ast +import inspect +from griffe import Docstring, Extension, Object, ObjectNode, get_logger, dynamic_import + +logger = get_logger(__name__) + + +class DynamicDocstrings(Extension): + def __init__(self, object_paths: list[str] | None = None) -> None: + self.object_paths = object_paths + + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + if isinstance(node, ObjectNode): + return # skip runtime objects, their docstrings are already right + + if self.object_paths and obj.path not in self.object_paths: + return # skip objects that were not selected + + # import object to get its evaluated docstring + try: + runtime_obj = dynamic_import(obj.path) + docstring = runtime_obj.__doc__ + except ImportError: + logger.debug(f"Could not get dynamic docstring for {obj.path}") + return + except AttributeError: + logger.debug(f"Object {obj.path} does not have a __doc__ attribute") + return + + # update the object instance with the evaluated docstring + docstring = inspect.cleandoc(docstring) + if obj.docstring: + obj.docstring.value = docstring + else: + obj.docstring = Docstring(docstring, parent=obj) +``` + +You can then expose this extension in the top-level module of your package: + +```python title="./src/dynamic_docstrings/__init__.py" +from dynamic_docstrings.extension import DynamicDocstrings + +__all__ = ["DynamicDocstrings"] +``` + +This will allow users to load and use this extension +by referring to it as `dynamic_docstrings` (your Python package name). + +See [how to use extensions](#using-extensions) to learn more +about how to load and use your new extension. ## AST nodes diff --git a/mkdocs.yml b/mkdocs.yml index 03f0cb91..84d8d5ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,7 +88,11 @@ markdown_extensions: - pymdownx.magiclink - pymdownx.snippets: check_paths: true -- pymdownx.superfences +- pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true slugify: !!python/object/apply:pymdownx.slugs.slugify diff --git a/src/griffe/__init__.py b/src/griffe/__init__.py index fc522877..76e1ffc3 100644 --- a/src/griffe/__init__.py +++ b/src/griffe/__init__.py @@ -7,8 +7,28 @@ from __future__ import annotations +from griffe.agents.nodes import ObjectNode +from griffe.dataclasses import Attribute, Class, Docstring, Function, Module, Object from griffe.diff import find_breaking_changes +from griffe.extensions import Extension, load_extensions from griffe.git import load_git +from griffe.importer import dynamic_import from griffe.loader import load +from griffe.logger import get_logger -__all__: list[str] = ["find_breaking_changes", "load", "load_git"] +__all__: list[str] = [ + "Attribute", + "Class", + "Docstring", + "dynamic_import", + "Extension", + "Function", + "find_breaking_changes", + "get_logger", + "load", + "load_extensions", + "load_git", + "Module", + "Object", + "ObjectNode", +] diff --git a/src/griffe/agents/inspector.py b/src/griffe/agents/inspector.py index f3f3c3fc..1c6deed4 100644 --- a/src/griffe/agents/inspector.py +++ b/src/griffe/agents/inspector.py @@ -215,7 +215,9 @@ def inspect_module(self, node: ObjectNode) -> None: Parameters: node: The node to inspect. """ - self.current = Module( + self.extensions.call("on_node", node=node) + self.extensions.call("on_module_node", node=node) + self.current = module = Module( name=self.module_name, filepath=self.filepath, parent=self.parent, @@ -223,7 +225,11 @@ def inspect_module(self, node: ObjectNode) -> None: lines_collection=self.lines_collection, modules_collection=self.modules_collection, ) + self.extensions.call("on_instance", node=node, obj=module) + self.extensions.call("on_module_instance", node=node, mod=module) self.generic_inspect(node) + self.extensions.call("on_members", node=node, obj=module) + self.extensions.call("on_module_members", node=node, mod=module) def inspect_class(self, node: ObjectNode) -> None: """Inspect a class. @@ -231,6 +237,9 @@ def inspect_class(self, node: ObjectNode) -> None: Parameters: node: The node to inspect. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_class_node", node=node) + bases = [] for base in node.obj.__bases__: if base is object: @@ -244,7 +253,11 @@ def inspect_class(self, node: ObjectNode) -> None: ) self.current.set_member(node.name, class_) self.current = class_ + self.extensions.call("on_instance", node=node, obj=class_) + self.extensions.call("on_class_instance", node=node, cls=class_) self.generic_inspect(node) + self.extensions.call("on_members", node=node, obj=class_) + self.extensions.call("on_class_members", node=node, cls=class_) self.current = self.current.parent # type: ignore[assignment] def inspect_staticmethod(self, node: ObjectNode) -> None: @@ -335,6 +348,9 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: node: The node to inspect. labels: Labels to add to the data object. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_function_node", node=node) + try: signature = getsignature(node.obj) except Exception: # noqa: BLE001 @@ -371,6 +387,11 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: ) obj.labels |= labels self.current.set_member(node.name, obj) + self.extensions.call("on_instance", node=node, obj=obj) + if obj.is_attribute: + self.extensions.call("on_attribute_instance", node=node, attr=obj) + else: + self.extensions.call("on_function_instance", node=node, func=obj) def inspect_attribute(self, node: ObjectNode) -> None: """Inspect an attribute. @@ -387,6 +408,9 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Name | Expression node: The node to inspect. annotation: A potentiel annotation. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_attribute_node", node=node) + # TODO: to improve parent = self.current labels: set[str] = set() @@ -421,6 +445,8 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Name | Expression if node.name == "__all__": parent.exports = set(node.obj) + self.extensions.call("on_instance", node=node, obj=attribute) + self.extensions.call("on_attribute_instance", node=node, attr=attribute) _kind_map = { diff --git a/src/griffe/agents/nodes/_runtime.py b/src/griffe/agents/nodes/_runtime.py index 80b0584f..18fec125 100644 --- a/src/griffe/agents/nodes/_runtime.py +++ b/src/griffe/agents/nodes/_runtime.py @@ -24,18 +24,31 @@ class ObjectKind(enum.Enum): """Enumeration for the different kinds of objects.""" MODULE: str = "module" + """Modules.""" CLASS: str = "class" + """Classes.""" STATICMETHOD: str = "staticmethod" + """Static methods.""" CLASSMETHOD: str = "classmethod" + """Class methods.""" METHOD_DESCRIPTOR: str = "method_descriptor" + """Method descriptors.""" METHOD: str = "method" + """Methods.""" BUILTIN_METHOD: str = "builtin_method" + """Built-in ethods.""" COROUTINE: str = "coroutine" + """Coroutines""" FUNCTION: str = "function" + """Functions.""" BUILTIN_FUNCTION: str = "builtin_function" + """Built-in functions.""" CACHED_PROPERTY: str = "cached_property" + """Cached properties.""" PROPERTY: str = "property" + """Properties.""" ATTRIBUTE: str = "attribute" + """Attributes.""" def __str__(self) -> str: return self.value diff --git a/src/griffe/agents/visitor.py b/src/griffe/agents/visitor.py index 4d975d44..55e301e6 100644 --- a/src/griffe/agents/visitor.py +++ b/src/griffe/agents/visitor.py @@ -211,7 +211,9 @@ def visit_module(self, node: ast.Module) -> None: Parameters: node: The node to visit. """ - module = Module( + self.extensions.call("on_node", node=node) + self.extensions.call("on_module_node", node=node) + self.current = module = Module( name=self.module_name, filepath=self.filepath, parent=self.parent, @@ -219,8 +221,11 @@ def visit_module(self, node: ast.Module) -> None: lines_collection=self.lines_collection, modules_collection=self.modules_collection, ) - self.current = module + self.extensions.call("on_instance", node=node, obj=module) + self.extensions.call("on_module_instance", node=node, mod=module) self.generic_visit(node) + self.extensions.call("on_members", node=node, obj=module) + self.extensions.call("on_module_members", node=node, mod=module) def visit_classdef(self, node: ast.ClassDef) -> None: """Visit a class definition node. @@ -228,6 +233,9 @@ def visit_classdef(self, node: ast.ClassDef) -> None: Parameters: node: The node to visit. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_class_node", node=node) + # handle decorators decorators = [] if node.decorator_list: @@ -261,7 +269,11 @@ def visit_classdef(self, node: ast.ClassDef) -> None: class_.labels |= self.decorators_to_labels(decorators) self.current.set_member(node.name, class_) self.current = class_ + self.extensions.call("on_instance", node=node, obj=class_) + self.extensions.call("on_class_instance", node=node, cls=class_) self.generic_visit(node) + self.extensions.call("on_members", node=node, obj=class_) + self.extensions.call("on_class_members", node=node, cls=class_) self.current = self.current.parent # type: ignore[assignment] def decorators_to_labels(self, decorators: list[Decorator]) -> set[str]: @@ -313,6 +325,9 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: node: The node to visit. labels: Labels to add to the data object. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_function_node", node=node) + labels = labels or set() # handle decorators @@ -348,6 +363,8 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: ) attribute.labels |= labels self.current.set_member(node.name, attribute) + self.extensions.call("on_instance", node=node, obj=attribute) + self.extensions.call("on_attribute_instance", node=node, attr=attribute) return # handle parameters @@ -456,6 +473,8 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: function.labels |= labels + self.extensions.call("on_instance", node=node, obj=function) + self.extensions.call("on_function_instance", node=node, func=function) if self.current.kind is Kind.CLASS and function.name == "__init__": self.current = function # type: ignore[assignment] # temporary assign a function self.generic_visit(node) @@ -541,6 +560,8 @@ def handle_attribute( node: The node to visit. annotation: A potential annotation. """ + self.extensions.call("on_node", node=node) + self.extensions.call("on_attribute_node", node=node) parent = self.current labels = set() @@ -625,6 +646,8 @@ def handle_attribute( if name == "__all__": with suppress(AttributeError): parent.exports = safe_get__all__(node, self.current) # type: ignore[arg-type] + self.extensions.call("on_instance", node=node, obj=attribute) + self.extensions.call("on_attribute_instance", node=node, attr=attribute) def visit_assign(self, node: ast.Assign) -> None: """Visit an assignment node. diff --git a/src/griffe/cli.py b/src/griffe/cli.py index d71e3ab5..6e290836 100644 --- a/src/griffe/cli.py +++ b/src/griffe/cli.py @@ -35,7 +35,7 @@ from griffe.stats import _format_stats if TYPE_CHECKING: - from griffe.extensions import Extension, Extensions + from griffe.extensions import Extensions, ExtensionType DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper() @@ -98,6 +98,13 @@ def _load_packages( _level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") +def _extensions_type(value: str) -> Sequence[str | dict[str, Any]]: + try: + return json.loads(value) + except json.JSONDecodeError: + return value.split(",") + + def get_parser() -> argparse.ArgumentParser: """Return the CLI argument parser. @@ -133,7 +140,7 @@ def add_common_options(subparser: argparse.ArgumentParser) -> None: "-e", "--extensions", default={}, - type=json.loads, + type=_extensions_type, help="A list of extensions to use.", ) loading_options.add_argument( @@ -277,7 +284,7 @@ def dump( full: bool = False, docstring_parser: Parser | None = None, docstring_options: dict[str, Any] | None = None, - extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, + extensions: Sequence[str | dict[str, Any] | ExtensionType | type[ExtensionType]] | None = None, resolve_aliases: bool = False, resolve_implicit: bool = False, resolve_external: bool = False, @@ -356,7 +363,7 @@ def check( against_path: str | Path | None = None, *, base_ref: str | None = None, - extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, + extensions: Sequence[str | dict[str, Any] | ExtensionType | type[ExtensionType]] | None = None, search_paths: Sequence[str | Path] | None = None, allow_inspection: bool = True, verbose: bool = False, diff --git a/src/griffe/extensions/__init__.py b/src/griffe/extensions/__init__.py index 7557172c..2138fe5f 100644 --- a/src/griffe/extensions/__init__.py +++ b/src/griffe/extensions/__init__.py @@ -3,6 +3,7 @@ from griffe.extensions.base import ( Extension, Extensions, + ExtensionType, InspectorExtension, VisitorExtension, When, @@ -13,6 +14,7 @@ __all__ = [ "Extensions", "Extension", + "ExtensionType", "InspectorExtension", "VisitorExtension", "When", diff --git a/src/griffe/extensions/base.py b/src/griffe/extensions/base.py index 575e71a1..959514e7 100644 --- a/src/griffe/extensions/base.py +++ b/src/griffe/extensions/base.py @@ -5,6 +5,7 @@ import enum import os import sys +import warnings from collections import defaultdict from importlib.util import module_from_spec, spec_from_file_location from inspect import isclass @@ -15,12 +16,13 @@ from griffe.importer import dynamic_import if TYPE_CHECKING: - from ast import AST + import ast from types import ModuleType from griffe.agents.inspector import Inspector from griffe.agents.nodes import ObjectNode from griffe.agents.visitor import Visitor + from griffe.dataclasses import Attribute, Class, Function, Module, Object class When(enum.Enum): @@ -46,7 +48,12 @@ class VisitorExtension: def __init__(self) -> None: """Initialize the visitor extension.""" - super().__init__() + warnings.warn( + "Visitor extensions are deprecated in favor of the new, more developer-friendly Extension. " + "See https://mkdocstrings.github.io/griffe/extensions/", + DeprecationWarning, + stacklevel=1, + ) self.visitor: Visitor = None # type: ignore[assignment] def attach(self, visitor: Visitor) -> None: @@ -57,7 +64,7 @@ def attach(self, visitor: Visitor) -> None: """ self.visitor = visitor - def visit(self, node: AST) -> None: + def visit(self, node: ast.AST) -> None: """Visit a node. Parameters: @@ -73,7 +80,12 @@ class InspectorExtension: def __init__(self) -> None: """Initialize the inspector extension.""" - super().__init__() + warnings.warn( + "Inspector extensions are deprecated in favor of the new, more developer-friendly Extension. " + "See https://mkdocstrings.github.io/griffe/extensions/", + DeprecationWarning, + stacklevel=1, + ) self.inspector: Inspector = None # type: ignore[assignment] def attach(self, inspector: Inspector) -> None: @@ -93,13 +105,151 @@ def inspect(self, node: ObjectNode) -> None: getattr(self, f"inspect_{node.kind}", lambda _: None)(node) -Extension = Union[VisitorExtension, InspectorExtension] +class Extension: + """Base class for Griffe extensions.""" + + def visit(self, node: ast.AST) -> None: + """Visit a node. + + Parameters: + node: The node to visit. + """ + getattr(self, f"visit_{ast_kind(node)}", lambda _: None)(node) + + def generic_visit(self, node: ast.AST) -> None: + """Visit children nodes. + + Parameters: + node: The node to visit the children of. + """ + for child in ast_children(node): + self.visit(child) + + def inspect(self, node: ObjectNode) -> None: + """Inspect a node. + + Parameters: + node: The node to inspect. + """ + getattr(self, f"inspect_{node.kind}", lambda _: None)(node) + + def generic_inspect(self, node: ObjectNode) -> None: + """Extend the base generic inspection with extensions. + + Parameters: + node: The node to inspect. + """ + for child in node.children: + if not child.alias_target_path: + self.inspect(child) + + def on_node(self, *, node: ast.AST | ObjectNode) -> None: + """Run when visiting a new node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + """ + + def on_instance(self, *, node: ast.AST | ObjectNode, obj: Object) -> None: + """Run when an Object has been created. + + Parameters: + node: The currently visited node. + obj: The object instance. + """ + + def on_members(self, *, node: ast.AST | ObjectNode, obj: Object) -> None: + """Run when members of an Object have been loaded. + + Parameters: + node: The currently visited node. + obj: The object instance. + """ + + def on_module_node(self, *, node: ast.AST | ObjectNode) -> None: + """Run when visiting a new module node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + """ + + def on_module_instance(self, *, node: ast.AST | ObjectNode, mod: Module) -> None: + """Run when a Module has been created. + + Parameters: + node: The currently visited node. + mod: The module instance. + """ + + def on_module_members(self, *, node: ast.AST | ObjectNode, mod: Module) -> None: + """Run when members of a Module have been loaded. + + Parameters: + node: The currently visited node. + mod: The module instance. + """ + + def on_class_node(self, *, node: ast.AST | ObjectNode) -> None: + """Run when visiting a new class node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + """ + + def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class) -> None: + """Run when a Class has been created. + + Parameters: + node: The currently visited node. + cls: The class instance. + """ + + def on_class_members(self, *, node: ast.AST | ObjectNode, cls: Class) -> None: + """Run when members of a Class have been loaded. + + Parameters: + node: The currently visited node. + cls: The class instance. + """ + + def on_function_node(self, *, node: ast.AST | ObjectNode) -> None: + """Run when visiting a new function node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + """ + + def on_function_instance(self, *, node: ast.AST | ObjectNode, func: Function) -> None: + """Run when a Function has been created. + + Parameters: + node: The currently visited node. + func: The function instance. + """ + + def on_attribute_node(self, *, node: ast.AST | ObjectNode) -> None: + """Run when visiting a new attribute node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + """ + + def on_attribute_instance(self, *, node: ast.AST | ObjectNode, attr: Attribute) -> None: + """Run when an Attribute has been created. + + Parameters: + node: The currently visited node. + attr: The attribute instance. + """ + + +ExtensionType = Union[VisitorExtension, InspectorExtension, Extension] class Extensions: """This class helps iterating on extensions that should run at different times.""" - def __init__(self, *extensions: Extension) -> None: + def __init__(self, *extensions: ExtensionType) -> None: """Initialize the extensions container. Parameters: @@ -107,9 +257,10 @@ def __init__(self, *extensions: Extension) -> None: """ self._visitors: dict[When, list[VisitorExtension]] = defaultdict(list) self._inspectors: dict[When, list[InspectorExtension]] = defaultdict(list) + self._extensions: list[Extension] = [] self.add(*extensions) - def add(self, *extensions: Extension) -> None: + def add(self, *extensions: ExtensionType) -> None: """Add extensions to this container. Parameters: @@ -118,8 +269,10 @@ def add(self, *extensions: Extension) -> None: for extension in extensions: if isinstance(extension, VisitorExtension): self._visitors[extension.when].append(extension) - else: + elif isinstance(extension, InspectorExtension): self._inspectors[extension.when].append(extension) + else: + self._extensions.append(extension) def attach_visitor(self, parent_visitor: Visitor) -> Extensions: """Attach a parent visitor to the visitor extensions. @@ -221,6 +374,17 @@ def after_inspection(self) -> list[InspectorExtension]: """ return self._inspectors[When.after_all] + def call(self, event: str, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: + """Call the extension hook for the given event. + + Parameters: + event: The trigerred event. + node: The AST or Object node. + **kwargs: Additional arguments like a Griffe object. + """ + for extension in self._extensions: + getattr(extension, event)(node=node, **kwargs) + builtin_extensions: set[str] = { "hybrid", @@ -238,7 +402,9 @@ def _load_extension_path(path: str) -> ModuleType: return module -def load_extension(extension: str | dict[str, Any] | Extension | type[Extension]) -> Extension: +def load_extension( + extension: str | dict[str, Any] | ExtensionType | type[ExtensionType], +) -> ExtensionType | list[ExtensionType]: """Load a configured extension. Parameters: @@ -254,28 +420,44 @@ def load_extension(extension: str | dict[str, Any] | Extension | type[Extension] An extension instance. """ ext_object = None + ext_classes = (VisitorExtension, InspectorExtension, Extension) - if isinstance(extension, (VisitorExtension, InspectorExtension)): + # If it's already an extension instance, return it. + if isinstance(extension, ext_classes): return extension - if isclass(extension) and issubclass(extension, (VisitorExtension, InspectorExtension)): + # If it's an extension class, instantiate it (without options) and return it. + if isclass(extension) and issubclass(extension, ext_classes): return extension() + # If it's a dictionary, we expect the only key to be an import path + # and the value to be a dictionary of options. if isinstance(extension, dict): import_path, options = next(iter(extension.items())) - else: # we consider it's a string + # Otherwise we consider it's an import path, without options. + else: import_path = str(extension) options = {} + # If the import path contains a colon, we split into path and class name. + if ":" in import_path: + import_path, extension_name = import_path.split(":", 1) + else: + extension_name = None + + # If the import path corresponds to a built-in extension, expand it. if import_path in builtin_extensions: import_path = f"griffe.extensions.{import_path}" + # If the import path is a path to an existing file, load it. elif os.path.exists(import_path): try: ext_object = _load_extension_path(import_path) except ImportError as error: raise ExtensionNotLoadedError(f"Extension module '{import_path}' could not be found") from error + # If the extension wasn't loaded yet, we consider the import path + # to be a Python dotted path like `package.module` or `package.module.Extension`. if not ext_object: try: ext_object = dynamic_import(import_path) @@ -284,16 +466,30 @@ def load_extension(extension: str | dict[str, Any] | Extension | type[Extension] except ImportError as error: raise ExtensionNotLoadedError(f"Error while importing extension '{import_path}': {error}") from error - if isclass(ext_object) and issubclass(ext_object, (VisitorExtension, InspectorExtension)): + # If the loaded object is an extension class, instantiate it with options and return it. + if isclass(ext_object) and issubclass(ext_object, ext_classes): return ext_object(**options) # type: ignore[misc] - try: - return ext_object.Extension(**options) # type: ignore[union-attr] - except AttributeError as error: - raise ExtensionNotLoadedError(f"Extension module '{import_path}' has no 'Extension' attribute") from error - - -def load_extensions(exts: Sequence[str | dict[str, Any] | Extension | type[Extension]]) -> Extensions: + # Otherwise the loaded object is a module, so we get the extension class by name, + # instantiate it with options and return it. + if extension_name: + try: + return getattr(ext_object, extension_name)(**options) + except AttributeError as error: + raise ExtensionNotLoadedError( + f"Extension module '{import_path}' has no '{extension_name}' attribute", + ) from error + + # No class name was specified so we search all extension classes in the module + # instantiate each with the same options, and return them. + extensions = [] + for obj in vars(ext_object).values(): + if isclass(obj) and issubclass(obj, ext_classes) and obj not in ext_classes: + extensions.append(obj) + return [ext(**options) for ext in extensions] + + +def load_extensions(exts: Sequence[str | dict[str, Any] | ExtensionType | type[ExtensionType]]) -> Extensions: """Load configured extensions. Parameters: @@ -304,5 +500,9 @@ def load_extensions(exts: Sequence[str | dict[str, Any] | Extension | type[Exten """ extensions = Extensions() for extension in exts: - extensions.add(load_extension(extension)) + ext = load_extension(extension) + if isinstance(ext, list): + extensions.add(*ext) + else: + extensions.add(ext) return extensions diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 00000000..a846ff52 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,132 @@ +"""Tests for the `extensions` module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from griffe.extensions import Extension, load_extensions +from griffe.tests import temporary_visited_module + +if TYPE_CHECKING: + import ast + + from griffe.agents.nodes import ObjectNode + from griffe.dataclasses import Attribute, Class, Function, Module, Object + + +class ExtensionTest(Extension): # noqa: D101 + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107 + super().__init__() + self.records: list[str] = [] + self.args = args + self.kwargs = kwargs + + def on_attribute_instance(self, *, node: ast.AST | ObjectNode, attr: Attribute) -> None: # noqa: D102,ARG002 + self.records.append("on_attribute_instance") + + def on_attribute_node(self, *, node: ast.AST | ObjectNode) -> None: # noqa: D102,ARG002 + self.records.append("on_attribute_node") + + def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class) -> None: # noqa: D102,ARG002 + self.records.append("on_class_instance") + + def on_class_members(self, *, node: ast.AST | ObjectNode, cls: Class) -> None: # noqa: D102,ARG002 + self.records.append("on_class_members") + + def on_class_node(self, *, node: ast.AST | ObjectNode) -> None: # noqa: D102,ARG002 + self.records.append("on_class_node") + + def on_function_instance(self, *, node: ast.AST | ObjectNode, func: Function) -> None: # noqa: D102,ARG002 + self.records.append("on_function_instance") + + def on_function_node(self, *, node: ast.AST | ObjectNode) -> None: # noqa: D102,ARG002 + self.records.append("on_function_node") + + def on_instance(self, *, node: ast.AST | ObjectNode, obj: Object) -> None: # noqa: D102,ARG002 + self.records.append("on_instance") + + def on_members(self, *, node: ast.AST | ObjectNode, obj: Object) -> None: # noqa: D102,ARG002 + self.records.append("on_members") + + def on_module_instance(self, *, node: ast.AST | ObjectNode, mod: Module) -> None: # noqa: D102,ARG002 + self.records.append("on_module_instance") + + def on_module_members(self, *, node: ast.AST | ObjectNode, mod: Module) -> None: # noqa: D102,ARG002 + self.records.append("on_module_members") + + def on_module_node(self, *, node: ast.AST | ObjectNode) -> None: # noqa: D102,ARG002 + self.records.append("on_module_node") + + def on_node(self, *, node: ast.AST | ObjectNode) -> None: # noqa: D102,ARG002 + self.records.append("on_node") + + +@pytest.mark.parametrize( + "extension", + [ + # with module path + "tests.test_extensions", + {"tests.test_extensions": {"option": 0}}, + # with extension path + "tests.test_extensions.ExtensionTest", + {"tests.test_extensions.ExtensionTest": {"option": 0}}, + # with filepath + "tests/test_extensions.py", + {"tests/test_extensions.py": {"option": 0}}, + # with filepath and extension name + "tests/test_extensions.py:ExtensionTest", + {"tests/test_extensions.py:ExtensionTest": {"option": 0}}, + # with instance + ExtensionTest(option=0), + # with class + ExtensionTest, + ], +) +def test_loading_extensions(extension: str | dict[str, dict[str, Any]] | Extension | type[Extension]) -> None: + """Test the extensions loading mechanisms. + + Parameters: + extension: Extension specification (parametrized). + """ + extensions = load_extensions([extension]) + loaded: ExtensionTest = extensions._extensions[0] # type: ignore[assignment] + # We cannot use isinstance here, + # because loading from a filepath drops the parent `tests` package, + # resulting in a different object than the present ExtensionTest. + assert loaded.__class__.__name__ == "ExtensionTest" + if isinstance(extension, (dict, ExtensionTest)): + assert loaded.kwargs == {"option": 0} + + +def test_extension_events() -> None: + """Test events triggering.""" + extension = ExtensionTest() + with temporary_visited_module( + """ + attr = 0 + def func(): ... + class Class: + cattr = 1 + def method(self): ... + """, + extensions=load_extensions([extension]), + ): + pass + events = [ + "on_attribute_instance", + "on_attribute_node", + "on_class_instance", + "on_class_members", + "on_class_node", + "on_function_instance", + "on_function_node", + "on_instance", + "on_members", + "on_module_instance", + "on_module_members", + "on_module_node", + "on_node", + ] + assert set(events) == set(extension.records)