From bfe5135c6e8b99dab71dd6fe3eecdafeb74617df Mon Sep 17 00:00:00 2001 From: Victor Westerhuis Date: Mon, 23 Dec 2024 17:05:37 +0100 Subject: [PATCH 1/8] Implement PEP 695 type parameter syntax --- docs/guide/users/checking.md | 2 +- docs/guide/users/extending.md | 26 +- docs/guide/users/how-to/support-decorators.md | 2 +- docs/guide/users/navigating.md | 17 +- .../guide/users/recommendations/docstrings.md | 22 +- docs/guide/users/serializing.md | 2 +- docs/introduction.md | 2 +- docs/reference/api/models.md | 15 +- docs/reference/api/models/type_alias.md | 3 + docs/schema.json | 91 +++++- mkdocs.yml | 1 + src/_griffe/agents/inspector.py | 136 ++++++++- src/_griffe/agents/nodes/runtime.py | 21 ++ src/_griffe/agents/visitor.py | 97 +++++- src/_griffe/encoders.py | 61 +++- src/_griffe/enumerations.py | 19 ++ src/_griffe/extensions/base.py | 28 +- src/_griffe/importer.py | 2 +- src/_griffe/loader.py | 4 +- src/_griffe/merger.py | 16 +- src/_griffe/mixins.py | 17 +- src/_griffe/models.py | 281 +++++++++++++++++- src/_griffe/stats.py | 5 +- src/griffe/__init__.py | 8 + tests/test_encoders.py | 101 ++++++- tests/test_extensions.py | 47 ++- tests/test_inspector.py | 103 +++++++ tests/test_models.py | 37 +++ tests/test_visitor.py | 102 +++++++ 29 files changed, 1204 insertions(+), 64 deletions(-) create mode 100644 docs/reference/api/models/type_alias.md diff --git a/docs/guide/users/checking.md b/docs/guide/users/checking.md index 0531baa3f..07cd8881d 100644 --- a/docs/guide/users/checking.md +++ b/docs/guide/users/checking.md @@ -544,7 +544,7 @@ print(module.special_thing) > Public object points to a different kind of object -Changing the kind (attribute, function, class, module) of a public object can *silently* break your users' code. +Changing the kind (type alias, attribute, function, class, module) of a public object can *silently* break your users' code. ```python title="before" # your code diff --git a/docs/guide/users/extending.md b/docs/guide/users/extending.md index 92f727d08..8a721c562 100644 --- a/docs/guide/users/extending.md +++ b/docs/guide/users/extending.md @@ -102,7 +102,7 @@ If the source code is not available (the modules are built-in or compiled), Grif 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.Visitor] and for object trees, it uses its [Inspector agent][griffe.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.Object] subclass, such as a [Module][griffe.Module], a [Class][griffe.Class], a [Function][griffe.Function], or an [Attribute][griffe.Attribute]. Extensions will therefore be able to modify these instances. +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.Object] subclass, such as a [Module][griffe.Module], a [Class][griffe.Class], a [Function][griffe.Function], an [Attribute][griffe.Attribute], or a [Type Alias][griffe.TypeAlias]. Extensions will therefore be able to modify these instances. 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]. @@ -200,8 +200,8 @@ There are two **load events**: There are 3 generic **analysis events**: - [`on_node`][griffe.Extension.on_node]: The "on node" events are triggered when the agent (visitor or inspector) starts handling a node in the tree (AST or object tree). -- [`on_instance`][griffe.Extension.on_instance]: The "on instance" events are triggered when the agent just created an instance of [Module][griffe.Module], [Class][griffe.Class], [Function][griffe.Function], or [Attribute][griffe.Attribute], and added it as a member of its parent. The "on instance" event is **not** triggered when an [Alias][griffe.Alias] is created. -- [`on_members`][griffe.Extension.on_members]: 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. +- [`on_instance`][griffe.Extension.on_instance]: The "on instance" events are triggered when the agent just created an instance of [Module][griffe.Module], [Class][griffe.Class], [Function][griffe.Function], [Attribute][griffe.Attribute], or [Type Alias][griffe.TypeAlias], and added it as a member of its parent. The "on instance" event is **not** triggered when an [Alias][griffe.Alias] is created. +- [`on_members`][griffe.Extension.on_members]: The "on members" events are triggered when the agent just finished handling all the members of an object. Functions, attributes and type aliases do not have members, so there are no "on members" event for these two kinds. There are also specific **analysis events** for each object kind: @@ -215,6 +215,8 @@ There are also specific **analysis events** for each object kind: - [`on_function_instance`][griffe.Extension.on_function_instance] - [`on_attribute_node`][griffe.Extension.on_attribute_node] - [`on_attribute_instance`][griffe.Extension.on_attribute_instance] +- [`on_type_alias_node`][griffe.Extension.on_type_alias_node] +- [`on_type_alias_instance`][griffe.Extension.on_type_alias_instance] And a special event for aliases: @@ -315,7 +317,7 @@ class MyExtension(Extension): The preferred method is to check the type of the received node rather than the agent. -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. And since we always add `**kwargs` to the hooks' signatures, you can drop any parameter you don't use from the signature: +Since hooks also receive instantiated modules, classes, functions, attributes and type aliases, 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. And since we always add `**kwargs` to the hooks' signatures, you can drop any parameter you don't use from the signature: ```python import griffe @@ -391,7 +393,7 @@ class MyExtension(griffe.Extension): ### 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. +All Griffe objects (modules, classes, functions, attributes, type aliases) 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 griffe @@ -563,10 +565,10 @@ See [how to use extensions](#using-extensions) to learn more about how to load a > - [`Continue`][ast.Continue] > - [`Del`][ast.Del] > - [`Delete`][ast.Delete] +> - [`Dict`][ast.Dict] > > > -> - [`Dict`][ast.Dict] > - [`DictComp`][ast.DictComp] > - [`Div`][ast.Div] > - `Ellipsis`[^1] @@ -595,11 +597,11 @@ See [how to use extensions](#using-extensions) to learn more about how to load a > - [`IsNot`][ast.IsNot] > - [`JoinedStr`][ast.JoinedStr] > - [`keyword`][ast.keyword] +> - [`Lambda`][ast.Lambda] +> - [`List`][ast.List] > > > -> - [`Lambda`][ast.Lambda] -> - [`List`][ast.List] > - [`ListComp`][ast.ListComp] > - [`Load`][ast.Load] > - [`LShift`][ast.LShift] @@ -627,11 +629,12 @@ See [how to use extensions](#using-extensions) to learn more about how to load a > - [`NotEq`][ast.NotEq] > - [`NotIn`][ast.NotIn] > - `Num`[^1] +> - [`Or`][ast.Or] +> - [`ParamSpec`][ast.ParamSpec] +> - [`Pass`][ast.Pass] > > > -> - [`Or`][ast.Or] -> - [`Pass`][ast.Pass] > - `pattern`[^3] > - [`Pow`][ast.Pow] > - `Print`[^4] @@ -650,6 +653,9 @@ See [how to use extensions](#using-extensions) to learn more about how to load a > - `TryExcept`[^5] > - `TryFinally`[^6] > - [`Tuple`][ast.Tuple] +> - [`TypeAlias`][ast.TypeAlias] +> - [`TypeVar`][ast.TypeVar] +> - [`TypeVarTuple`][ast.TypeVarTuple] > - [`UAdd`][ast.UAdd] > - [`UnaryOp`][ast.UnaryOp] > - [`USub`][ast.USub] diff --git a/docs/guide/users/how-to/support-decorators.md b/docs/guide/users/how-to/support-decorators.md index 4b40a0bab..4da6f3e20 100644 --- a/docs/guide/users/how-to/support-decorators.md +++ b/docs/guide/users/how-to/support-decorators.md @@ -28,7 +28,7 @@ class MyDecorator(griffe.Extension): """An extension to suport my decorator.""" ``` -Now we can declare the [`on_instance`][griffe.Extension.on_instance] hook, which receives any kind of Griffe object ([`Module`][griffe.Module], [`Class`][griffe.Class], [`Function`][griffe.Function], [`Attribute`][griffe.Attribute]), or we could use a kind-specific hook such as [`on_module_instance`][griffe.Extension.on_module_instance], [`on_class_instance`][griffe.Extension.on_class_instance], [`on_function_instance`][griffe.Extension.on_function_instance] and [`on_attribute_instance`][griffe.Extension.on_attribute_instance]. For example, if you know your decorator is only ever used on class declarations, it would make sense to use `on_class_instance`. +Now we can declare the [`on_instance`][griffe.Extension.on_instance] hook, which receives any kind of Griffe object ([`Module`][griffe.Module], [`Class`][griffe.Class], [`Function`][griffe.Function], [`Attribute`][griffe.Attribute], [`TypeAlias`][griffe.TypeAlias]), or we could use a kind-specific hook such as [`on_module_instance`][griffe.Extension.on_module_instance], [`on_class_instance`][griffe.Extension.on_class_instance], [`on_function_instance`][griffe.Extension.on_function_instance], [`on_attribute_instance`][griffe.Extension.on_attribute_instance] and [`on_type_alias_instance`][griffe.Extension.on_type_alias_instance]. For example, if you know your decorator is only ever used on class declarations, it would make sense to use `on_class_instance`. For the example, lets use the `on_function_instance` hook, which receives `Function` instances. diff --git a/docs/guide/users/navigating.md b/docs/guide/users/navigating.md index fa30fe7e4..a9da754e8 100644 --- a/docs/guide/users/navigating.md +++ b/docs/guide/users/navigating.md @@ -6,6 +6,7 @@ Griffe loads API data into data models. These models provide various attributes - [`Class`][griffe.Class], representing Python classes; - [`Function`][griffe.Function], representing Python functions and class methods; - [`Attribute`][griffe.Attribute], representing object attributes that weren't identified as modules, classes or functions; +- [`Type Alias`][griffe.TypeAlias], representing Python type aliases; - [`Alias`][griffe.Alias], representing indirections such as imported objects or class members inherited from parent classes. When [loading an object](loading.md), Griffe will give you back an instance of one of these models. A few examples: @@ -84,7 +85,7 @@ To access an object's members, there are a few options: In particular, Griffe extensions should always use `get_member` instead of the subscript syntax `[]`. The `get_member` method only looks into regular members, while the subscript syntax looks into inherited members too (for classes), which cannot be correctly computed until a package is fully loaded (which is generally not the case when an extension is running). -- In addition to this, models provide the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes] or [`modules`][griffe.Object.modules] attributes, which return only members of the corresponding kind. These attributes are computed dynamically each time (they are Python properties). +- In addition to this, models provide the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes], [`type_aliases`][griffe.Object.type_aliases] or [`modules`][griffe.Object.modules] attributes, which return only members of the corresponding kind. These attributes are computed dynamically each time (they are Python properties). The same way members are accessed, they can also be set: @@ -121,7 +122,7 @@ If a base class cannot be resolved during computation of inherited members, Grif If you want to access all members at once (both declared and inherited), use the [`all_members`][griffe.Object.all_members] attribute. If you want to access only declared members, use the [`members`][griffe.Object] attribute. -Accessing the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes] or [`modules`][griffe.Object.modules] attributes will trigger inheritance computation, so make sure to only access them once everything is loaded by Griffe. Don't try to access inherited members in extensions, while visiting or inspecting modules. +Accessing the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes], [`type_aliases`][griffe.Object.type_aliases] or [`modules`][griffe.Object.modules] attributes will trigger inheritance computation, so make sure to only access them once everything is loaded by Griffe. Don't try to access inherited members in extensions, while visiting or inspecting modules. #### Limitations @@ -218,7 +219,7 @@ Aliases chains are never partially resolved: either they are resolved down to th ## Object kind -The kind of an object (module, class, function, attribute or alias) can be obtained in several ways. +The kind of an object (module, class, function, attribute, type alias or alias) can be obtained in several ways. - With the [`kind`][griffe.Object.kind] attribute and the [`Kind`][griffe.Kind] enumeration: `obj.kind is Kind.MODULE`. @@ -230,7 +231,7 @@ The kind of an object (module, class, function, attribute or alias) can be obtai When given a set of kinds, the method returns true if the object is of one of the given kinds. -- With the [`is_module`][griffe.Object.is_module], [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], and [`is_alias`][griffe.Object.is_alias] attributes. +- With the [`is_module`][griffe.Object.is_module], [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], [`is_type_alias`][griffe.Object.is_type_alias], and [`is_alias`][griffe.Object.is_alias] attributes. Additionally, it is possible to check if an object is a sub-kind of module, with the following attributes: @@ -351,7 +352,7 @@ After a package is loaded, it is still possible to change the style used for spe Do note, however, that the `parsed` attribute is cached, and won't be reset when overriding the `parser` or `parser_options` values. -Docstrings have a [`parent`][griffe.Docstring.parent] field too, that is a reference to their respective module, class, function or attribute. +Docstrings have a [`parent`][griffe.Docstring.parent] field too, that is a reference to their respective module, class, function, attribute or type alias. ## Model-specific fields @@ -370,6 +371,7 @@ Models have most fields in common, but also have specific fields. - [`overloads`][griffe.Class.overloads]: A dictionary to store overloads for class-level methods. - [`decorators`][griffe.Class.decorators]: The [decorators][griffe.Decorator] applied to the class. - [`parameters`][griffe.Class.parameters]: The [parameters][griffe.Parameters] of the class' `__init__` method, if any. +- [`type_parameters`][griffe.Class.type_parameters]: The [type parameters][griffe.TypeParameters] of the class. ### Functions @@ -377,6 +379,7 @@ Models have most fields in common, but also have specific fields. - [`overloads`][griffe.Function.overloads]: The overloaded signatures of the function. - [`parameters`][griffe.Function.parameters]: The [parameters][griffe.Parameters] of the function. - [`returns`][griffe.Function.returns]: The type annotation of the returned value, in the form of an [expression][griffe.Expr]. The `annotation` field can also be used, for compatibility with attributes. +- [`type_parameters`][griffe.Function.type_parameters]: The [type parameters][griffe.TypeParameters] of the function. ### Attributes @@ -385,6 +388,10 @@ Models have most fields in common, but also have specific fields. - [`deleter`][griffe.Attribute.deleter]: The property deleter. - [`setter`][griffe.Attribute.setter]: The property setter. +### Type aliases +- [`value`][griffe.TypeAlias.value]: The value of the type alias, in the form of an [expression][griffe.Expr]. +- [`type_parameters`][griffe.TypeAlias.type_parameters]: The [type parameters][griffe.TypeParameters] of the type alias. + ### Alias - [`alias_lineno`][griffe.Alias.alias_lineno]: The alias line number (where the object is imported). diff --git a/docs/guide/users/recommendations/docstrings.md b/docs/guide/users/recommendations/docstrings.md index 7c50edc11..0f84c4f7b 100644 --- a/docs/guide/users/recommendations/docstrings.md +++ b/docs/guide/users/recommendations/docstrings.md @@ -4,11 +4,14 @@ Here are explanations on what docstrings are, and a few recommendations on how t ## Definition -A docstring is a line or block of text describing objects such as modules, classes, functions and attributes. They are written below the object signature or assignment, or appear as first expression in a module: +A docstring is a line or block of text describing objects such as modules, classes, functions, attributes and type aliases. They are written below the object signature or assignment, or appear as first expression in a module: ```python title="module.py" """This is the module docstring.""" +type X = dict[str, int] +"""This is a type alias docstring.""" + a = 0 """This is an attribute docstring.""" @@ -51,7 +54,7 @@ Whatever markup you choose, try to stay consistent within your code base. ## Styles -Docstrings can be written for modules, classes, functions, and attributes. But there are other aspects of a Python API that need to be documented, such as function parameters, returned values, and raised exceptions, to name a few. We could document everything in natural language, but that would make it hard for downstream tools such as documentation generators to extract information in a structured way, to allow dedicated rendering such as tables for parameters. +Docstrings can be written for modules, classes, functions, attributes, and type aliases. But there are other aspects of a Python API that need to be documented, such as function parameters, returned values, and raised exceptions, to name a few. We could document everything in natural language, but that would make it hard for downstream tools such as documentation generators to extract information in a structured way, to allow dedicated rendering such as tables for parameters. To compensate for the lack of structure in natural languages, docstring "styles" emerged. A docstring style is a micro-format for docstrings, allowing to structure the information by following a specific format. With the most popular Google and Numpydoc styles, information in docstrings is decomposed into **sections** of different kinds, for example "parameter" sections or "return" sections. Some kinds of section then support documenting multiple items, or support a single block of markup. For example, we can document multiple parameters in "parameter" sections, but a "note" section is only composed of a text block. @@ -136,7 +139,7 @@ When documenting objects acting as namespaces (modules, classes, enumerations), ## Modules -Module docstrings should briefly explain what the module contains, and for what purposes these objects can be used. If the documentation generator you chose does not support generating member summaries automatically, you might want to add docstrings sections for attributes, functions, classes and submodules. +Module docstrings should briefly explain what the module contains, and for what purposes these objects can be used. If the documentation generator you chose does not support generating member summaries automatically, you might want to add docstrings sections for attributes, functions, classes, type aliases and submodules. ```python title="package/__init__.py" """A generic package to demonstrate docstrings. @@ -305,6 +308,19 @@ class GhostTown: """The town's size.""" ``` +## Type aliases + +Type alias docstrings are written below their assignment. As usual, they should have a short summary, and an optional, longer body. + +```python +type Callback = typing.Callable[[int, str], typing.Any] +"""Callback type for Frobnicators. + +The first argument is the number of rounds to run, the second argument +is the name of the widget being frobnicated. +""" +``` + ## Exceptions, warnings Callables that raise exception or emit warnings can document each of these exceptions and warnings. Documenting them informs your users that they could or should catch the raised exceptions, or that they could filter or configure warnings differently. The description next to each exception or warning should explain how or when they are raised or emitted. diff --git a/docs/guide/users/serializing.md b/docs/guide/users/serializing.md index a4daafb85..8910b098d 100644 --- a/docs/guide/users/serializing.md +++ b/docs/guide/users/serializing.md @@ -52,7 +52,7 @@ See all the options for the `dump` command in the [CLI reference](../../referenc ## Python API -If you have read through the [Navigating](navigating.md) chapter, you know about our five data models for modules, classes, functions, attributes and aliases. Each one of these model provide the two following methods: +If you have read through the [Navigating](navigating.md) chapter, you know about our six data models for modules, classes, functions, attributes, type aliases and aliases. Each one of these model provide the two following methods: - [`as_json`][griffe.Object.as_json], which allows to serialize an object into JSON, - [`from_json`][griffe.Object.from_json], which allows to load JSON back into a model instance. diff --git a/docs/introduction.md b/docs/introduction.md index a145628a9..a7226fa6e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,6 +1,6 @@ # Introduction -Griffe is able to read Python source code and inspect objects at runtime to extract information about the API of a Python package. This information is then stored into data models (Python classes), and these model instances together form a tree that statically represent the package's API: starting with the top-level module, then descending into submodules, classes, functions and attributes. From there, it's possible to explore and exploit this API representation in various ways. +Griffe is able to read Python source code and inspect objects at runtime to extract information about the API of a Python package. This information is then stored into data models (Python classes), and these model instances together form a tree that statically represent the package's API: starting with the top-level module, then descending into submodules, classes, functions, attributes and type aliases. From there, it's possible to explore and exploit this API representation in various ways. ## Command line tool diff --git a/docs/reference/api/models.md b/docs/reference/api/models.md index 6eebf0eca..7b3580cbc 100644 --- a/docs/reference/api/models.md +++ b/docs/reference/api/models.md @@ -2,19 +2,20 @@ Griffe stores information extracted from Python source code into data models. -These models represent trees of objects, starting with modules, and containing classes, functions, and attributes. +These models represent trees of objects, starting with modules, and containing classes, functions, attributes, and type aliases. -Modules can have submodules, classes, functions and attributes. Classes can have nested classes, methods and attributes. Functions and attributes do not have any members. +Modules can have submodules, classes, functions, attributes, and type aliases. Classes can have nested classes, methods, attributes, and type aliases. Functions and attributes do not have any members. Indirections to objects declared in other modules are represented as "aliases". An alias therefore represents an imported object, and behaves almost exactly like the object it points to: it is a light wrapper around the object, with special methods and properties that allow to access the target's data transparently. -The 5 models: +The 6 models: - [`Module`][griffe.Module] - [`Class`][griffe.Class] - [`Function`][griffe.Function] - [`Attribute`][griffe.Attribute] - [`Alias`][griffe.Alias] +- [`TypeAlias`][griffe.TypeAlias] ## **Model kind enumeration** @@ -39,3 +40,11 @@ The 5 models: inherited_members: false ::: griffe.Object + +## **Models type parameter** + +::: griffe.TypeParameters + +::: griffe.TypeParameter + +::: griffe.TypeParameterKind diff --git a/docs/reference/api/models/type_alias.md b/docs/reference/api/models/type_alias.md new file mode 100644 index 000000000..367d906d5 --- /dev/null +++ b/docs/reference/api/models/type_alias.md @@ -0,0 +1,3 @@ +# Type Alias + +::: griffe.TypeAlias diff --git a/docs/schema.json b/docs/schema.json index cdbcfd60c..08c51884c 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -63,7 +63,8 @@ "module", "class", "function", - "attribute" + "attribute", + "type alias" ] }, "path": { @@ -183,7 +184,8 @@ "parameters": true, "returns": true, "value": true, - "annotation": true + "annotation": true, + "type_parameters": true }, "additionalProperties": false, "required": [ @@ -248,6 +250,14 @@ "endlineno" ] } + }, + "type_parameters": { + "title": "For classes, their type parameters.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/class/#griffe.Class.type_parameters", + "type": "array", + "items": { + "$ref": "#/$defs/type_parameter" + } } }, "required": [ @@ -304,6 +314,14 @@ "title": "For functions, their return annotation.", "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/function/#griffe.Function.returns", "$ref": "#/$defs/annotation" + }, + "type_parameters": { + "title": "For functions, their type parameters.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/function/#griffe.Function.type_parameters", + "type": "array", + "items": { + "$ref": "#/$defs/type_parameter" + } } }, "required": [ @@ -334,6 +352,35 @@ } } } + }, + { + "if": { + "properties": { + "kind": { + "const": "type alias" + } + } + }, + "then": { + "properties": { + "value": { + "title": "For type aliases, their value.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/type_alias/#griffe.TypeAlias.value", + "$ref": "#/$defs/annotation" + }, + "type_parameters": { + "title": "For type aliases, their type parameters.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/type_alias/#griffe.TypeAlias.type_parameters", + "type": "array", + "items": { + "$ref": "#/$defs/type_parameter" + } + } + }, + "required": [ + "value" + ] + } } ] } @@ -355,6 +402,44 @@ "$ref": "#/$defs/expression" } ] + }, + "type_parameter": { + "title": "Type Parameter.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/#griffe.TypeParameter", + "type": "object", + "properties": { + "name": { + "title": "The type parameter name.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/#griffe.TypeParameter.name", + "type": "string" + }, + "kind": { + "title": "The type parameter kind.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/#griffe.TypeParameter.kind", + "enum": [ + "type-var", + "type-var-tuple", + "param-spec" + ] + }, + "annotation": { + "title": "The type parameter bound or constraints.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/#griffe.TypeParameter.annotation", + "$ref": "#/$defs/annotation" + }, + "default": { + "title": "The type parameter default.", + "markdownDescription": "https://mkdocstrings.github.io/griffe/reference/api/models/#griffe.TypeParameter.default", + "$ref": "#/$defs/annotation" + } + }, + "additionalProperties": false, + "required": [ + "name", + "kind", + "annotation", + "default" + ] } } -} \ No newline at end of file +} diff --git a/mkdocs.yml b/mkdocs.yml index 3c49c0022..675d9da10 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - Class: reference/api/models/class.md - Function: reference/api/models/function.md - Attribute: reference/api/models/attribute.md + - Type Alias: reference/api/models/type_alias.md - Alias: reference/api/models/alias.md - Agents: reference/api/agents.md - Serializers: reference/api/serializers.md diff --git a/src/_griffe/agents/inspector.py b/src/_griffe/agents/inspector.py index d0d32dd5c..39d8ef730 100644 --- a/src/_griffe/agents/inspector.py +++ b/src/_griffe/agents/inspector.py @@ -4,27 +4,49 @@ from __future__ import annotations import ast +import functools +import sys +import types +import typing from inspect import Parameter as SignatureParameter -from inspect import Signature, cleandoc, getsourcelines +from inspect import Signature, cleandoc, getsourcelines, unwrap from inspect import signature as getsignature from typing import TYPE_CHECKING, Any from _griffe.agents.nodes.runtime import ObjectNode from _griffe.collections import LinesCollection, ModulesCollection -from _griffe.enumerations import Kind, ParameterKind -from _griffe.expressions import safe_get_annotation +from _griffe.enumerations import Kind, ParameterKind, TypeParameterKind +from _griffe.expressions import Expr, ExprBinOp, ExprSubscript, ExprTuple, safe_get_annotation from _griffe.extensions.base import Extensions, load_extensions from _griffe.importer import dynamic_import from _griffe.logger import logger -from _griffe.models import Alias, Attribute, Class, Docstring, Function, Module, Parameter, Parameters +from _griffe.models import ( + Alias, + Attribute, + Class, + Docstring, + Function, + Module, + Parameter, + Parameters, + TypeAlias, + TypeParameter, + TypeParameters, +) if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from _griffe.enumerations import Parser - from _griffe.expressions import Expr +_TYPING_MODULES: tuple[types.ModuleType, ...] +try: + import typing_extensions +except ImportError: + _TYPING_MODULES = (typing,) +else: + _TYPING_MODULES = (typing, typing_extensions) _empty = Signature.empty @@ -316,6 +338,7 @@ def inspect_class(self, node: ObjectNode) -> None: name=node.name, docstring=self._get_docstring(node), bases=bases, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), lineno=lineno, endlineno=endlineno, ) @@ -454,6 +477,7 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: name=node.name, parameters=parameters, returns=returns, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, @@ -466,6 +490,30 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: else: self.extensions.call("on_function_instance", node=node, func=obj, agent=self) + def inspect_type_alias(self, node: ObjectNode) -> None: + """Inspect a type alias. + + Parameters: + node: The node to inspect. + """ + self.extensions.call("on_node", node=node, agent=self) + self.extensions.call("on_type_alias_node", node=node, agent=self) + + lineno, endlineno = self._get_linenos(node) + + type_alias = TypeAlias( + name=node.name, + value=_convert_type_to_annotation(node.obj.__value__, self.current), + lineno=lineno, + endlineno=endlineno, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), + docstring=self._get_docstring(node), + parent=self.current, + ) + self.current.set_member(node.name, type_alias) + self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) + self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) + def inspect_attribute(self, node: ObjectNode) -> None: """Inspect an attribute. @@ -522,7 +570,7 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Expr | None = Non self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self) -_kind_map = { +_parameter_kind_map = { SignatureParameter.POSITIONAL_ONLY: ParameterKind.positional_only, SignatureParameter.POSITIONAL_OR_KEYWORD: ParameterKind.positional_or_keyword, SignatureParameter.VAR_POSITIONAL: ParameterKind.var_positional, @@ -536,7 +584,7 @@ def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> annotation = ( None if parameter.annotation is _empty else _convert_object_to_annotation(parameter.annotation, parent=parent) ) - kind = _kind_map[parameter.kind] + kind = _parameter_kind_map[parameter.kind] if parameter.default is _empty: default = None elif hasattr(parameter.default, "__name__"): @@ -565,3 +613,77 @@ def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Exp except SyntaxError: return obj return safe_get_annotation(annotation_node.body, parent=parent) # type: ignore[attr-defined] + + +_type_parameter_kind_map = { + getattr(module, attr): value + for attr, value in { + "TypeVar": TypeParameterKind.type_var, + "TypeVarTuple": TypeParameterKind.type_var_tuple, + "ParamSpec": TypeParameterKind.param_spec, + }.items() + for module in _TYPING_MODULES + if hasattr(module, attr) +} + + +def _convert_type_parameters( + obj: Any, + parent: Module | Class, +) -> list[TypeParameter]: + obj = unwrap(obj) + + if not hasattr(obj, "__type_params__"): + return [] + + type_parameters = [] + for type_parameter in obj.__type_params__: + bound = getattr(type_parameter, "__bound__", None) + if bound is not None: + bound = _convert_type_to_annotation(bound, parent=parent) + constraints: list[str | Expr] = [ + _convert_type_to_annotation(constraint, parent=parent) # type: ignore[misc] + for constraint in getattr(type_parameter, "__constraints__", ()) + ] + + if getattr(type_parameter, "has_default", lambda: False)(): + default = _convert_type_to_annotation( + type_parameter.__default__, + parent=parent, + ) + else: + default = None + + type_parameters.append( + TypeParameter( + type_parameter.__name__, + kind=_type_parameter_kind_map[type(type_parameter)], + bound=bound, + constraints=constraints or None, + default=default, + ), + ) + + return type_parameters + + +def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: + origin = typing.get_origin(obj) + + if origin is None: + return _convert_object_to_annotation(obj, parent=parent) + + args: Sequence[str | Expr | None] = [ + _convert_type_to_annotation(arg, parent=parent) for arg in typing.get_args(obj) + ] + + # YORE: EOL 3.9: Replace block with lines 2-3. + if sys.version_info >= (3, 10): + if origin is types.UnionType: + return functools.reduce(lambda left, right: ExprBinOp(left, "|", right), args) # type: ignore[arg-type] + + origin = _convert_type_to_annotation(origin, parent=parent) + if origin is None: + return None + + return ExprSubscript(origin, ExprTuple(args, implicit=True)) # type: ignore[arg-type] diff --git a/src/_griffe/agents/nodes/runtime.py b/src/_griffe/agents/nodes/runtime.py index 743c778e9..7ed02032a 100644 --- a/src/_griffe/agents/nodes/runtime.py +++ b/src/_griffe/agents/nodes/runtime.py @@ -4,6 +4,7 @@ import inspect import sys +import typing from functools import cached_property from typing import TYPE_CHECKING, Any, ClassVar @@ -11,8 +12,18 @@ from _griffe.logger import logger if TYPE_CHECKING: + import types from collections.abc import Sequence +_TYPING_MODULES: tuple[types.ModuleType, ...] +try: + import typing_extensions +except ImportError: + _TYPING_MODULES = (typing,) +else: + _TYPING_MODULES = (typing, typing_extensions) + + _builtin_module_names = {_.lstrip("_") for _ in sys.builtin_module_names} _cyclic_relationships = { ("os", "nt"), @@ -132,6 +143,8 @@ def kind(self) -> ObjectKind: return ObjectKind.FUNCTION if self.is_property: return ObjectKind.PROPERTY + if self.is_type_alias: + return ObjectKind.TYPE_ALIAS return ObjectKind.ATTRIBUTE @cached_property @@ -159,6 +172,14 @@ def is_function(self) -> bool: # `inspect.isfunction` returns `False` for partials. return inspect.isfunction(self.obj) or (callable(self.obj) and not self.is_class) + @cached_property + def is_type_alias(self) -> bool: + """Whether this node's object is a type alias.""" + return isinstance( + self.obj, + tuple(module.TypeAliasType for module in _TYPING_MODULES if hasattr(module, "TypeAliasType")), + ) + @cached_property def is_builtin_function(self) -> bool: """Whether this node's object is a builtin function.""" diff --git a/src/_griffe/agents/visitor.py b/src/_griffe/agents/visitor.py index 233b439e8..26d6c5226 100644 --- a/src/_griffe/agents/visitor.py +++ b/src/_griffe/agents/visitor.py @@ -4,8 +4,9 @@ from __future__ import annotations import ast +import sys from contextlib import suppress -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final from _griffe.agents.nodes.assignments import get_instance_names, get_names from _griffe.agents.nodes.ast import ( @@ -18,7 +19,7 @@ from _griffe.agents.nodes.imports import relative_to_absolute from _griffe.agents.nodes.parameters import get_parameters from _griffe.collections import LinesCollection, ModulesCollection -from _griffe.enumerations import Kind +from _griffe.enumerations import Kind, TypeParameterKind from _griffe.exceptions import AliasResolutionError, CyclicAliasError, LastNodeError from _griffe.expressions import ( Expr, @@ -29,7 +30,20 @@ safe_get_expression, ) from _griffe.extensions.base import Extensions, load_extensions -from _griffe.models import Alias, Attribute, Class, Decorator, Docstring, Function, Module, Parameter, Parameters +from _griffe.models import ( + Alias, + Attribute, + Class, + Decorator, + Docstring, + Function, + Module, + Parameter, + Parameters, + TypeAlias, + TypeParameter, + TypeParameters, +) if TYPE_CHECKING: from pathlib import Path @@ -190,6 +204,35 @@ def _get_docstring(self, node: ast.AST, *, strict: bool = False) -> Docstring | parser_options=self.docstring_options, ) + # YORE: EOL 3.11: Replace block with lines 2-36. + if sys.version_info >= (3, 12): + _type_parameter_kind_map: Final[dict[type[ast.type_param], TypeParameterKind]] = { + ast.TypeVar: TypeParameterKind.type_var, + ast.TypeVarTuple: TypeParameterKind.type_var_tuple, + ast.ParamSpec: TypeParameterKind.param_spec, + } + + def _get_type_parameters( + self, + statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias, + ) -> list[TypeParameter]: + return [ + TypeParameter( + type_param.name, # type: ignore[attr-defined] + kind=self._type_parameter_kind_map[type(type_param)], + bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current), + default=safe_get_annotation(getattr(type_param, "default_value", None), parent=self.current), + ) + for type_param in statement.type_params + ] + else: + + def _get_type_parameters( + self, + _statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, + ) -> list[TypeParameter]: + return [] + def get_module(self) -> Module: """Build and return the object representing the module attached to this visitor. @@ -200,7 +243,13 @@ def get_module(self) -> Module: """ # optimization: equivalent to ast.parse, but with optimize=1 to remove assert statements # TODO: with options, could use optimize=2 to remove docstrings - top_node = compile(self.code, mode="exec", filename=str(self.filepath), flags=ast.PyCF_ONLY_AST, optimize=1) + top_node = compile( + self.code, + mode="exec", + filename=str(self.filepath), + flags=ast.PyCF_ONLY_AST, + optimize=1, + ) self.visit(top_node) return self.current.module @@ -276,6 +325,7 @@ def visit_classdef(self, node: ast.ClassDef) -> None: endlineno=node.end_lineno, docstring=self._get_docstring(node), decorators=decorators, + type_parameters=TypeParameters(*self._get_type_parameters(node)), bases=bases, # type: ignore[arg-type] runtime=not self.type_guarded, ) @@ -402,6 +452,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: parameters=parameters, returns=safe_get_annotation(node.returns, parent=self.current), decorators=decorators, + type_parameters=TypeParameters(*self._get_type_parameters(node)), docstring=self._get_docstring(node), runtime=not self.type_guarded, parent=self.current, @@ -450,6 +501,44 @@ def visit_asyncfunctiondef(self, node: ast.AsyncFunctionDef) -> None: """ self.handle_function(node, labels={"async"}) + # YORE: EOL 3.11: Replace block with lines 2-36. + if sys.version_info >= (3, 12): + + def visit_typealias(self, node: ast.TypeAlias) -> None: + """Visit a type alias node. + + Parameters: + node: The node to visit. + """ + self.extensions.call("on_node", node=node, agent=self) + self.extensions.call("on_type_alias_node", node=node, agent=self) + + # A type alias's name attribute is syntactically a single NAME, + # but represented as an expression in the AST. + # https://jellezijlstra.github.io/pep695#ast + + name = node.name.id + + value = safe_get_expression(node.value, parent=self.current) + + try: + docstring = self._get_docstring(ast_next(node), strict=True) + except (LastNodeError, AttributeError): + docstring = None + + type_alias = TypeAlias( + name=name, + value=value, + lineno=node.lineno, + endlineno=node.end_lineno, + type_parameters=TypeParameters(*self._get_type_parameters(node)), + docstring=docstring, + parent=self.current, + ) + self.current.set_member(name, type_alias) + self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) + self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) + def visit_import(self, node: ast.Import) -> None: """Visit an import node. diff --git a/src/_griffe/encoders.py b/src/_griffe/encoders.py index 5cda844f7..d43290205 100644 --- a/src/_griffe/encoders.py +++ b/src/_griffe/encoders.py @@ -8,7 +8,7 @@ from typing import Any, Callable from _griffe import expressions -from _griffe.enumerations import Kind, ParameterKind +from _griffe.enumerations import Kind, ParameterKind, TypeParameterKind from _griffe.models import ( Alias, Attribute, @@ -20,6 +20,9 @@ Object, Parameter, Parameters, + TypeAlias, + TypeParameter, + TypeParameters, ) _json_encoder_map: dict[type, Callable[[Any], Any]] = { @@ -122,6 +125,15 @@ def _load_parameter(obj_dict: dict[str, Any]) -> Parameter: ) +def _load_type_parameter(obj_dict: dict[str, Any]) -> TypeParameter: + return TypeParameter( + obj_dict["name"], + kind=TypeParameterKind(obj_dict["kind"]), + bound=obj_dict["annotation"], + default=obj_dict["default"], + ) + + def _attach_parent_to_expr(expr: expressions.Expr | str | None, parent: Module | Class) -> None: if not isinstance(expr, expressions.Expr): return @@ -132,7 +144,7 @@ def _attach_parent_to_expr(expr: expressions.Expr | str | None, parent: Module | elem.first.parent = parent -def _attach_parent_to_exprs(obj: Class | Function | Attribute, parent: Module | Class) -> None: +def _attach_parent_to_exprs(obj: Class | Function | Attribute | TypeAlias, parent: Module | Class) -> None: # Every name and attribute expression must be reattached # to its parent Griffe object (using its `parent` attribute), # to allow resolving names. @@ -141,11 +153,17 @@ def _attach_parent_to_exprs(obj: Class | Function | Attribute, parent: Module | _attach_parent_to_expr(obj.docstring.value, parent) for decorator in obj.decorators: _attach_parent_to_expr(decorator.value, parent) + for type_parameter in obj.type_parameters: + _attach_parent_to_expr(type_parameter.annotation, parent) + _attach_parent_to_expr(type_parameter.default, parent) elif isinstance(obj, Function): if obj.docstring: _attach_parent_to_expr(obj.docstring.value, parent) for decorator in obj.decorators: _attach_parent_to_expr(decorator.value, parent) + for type_parameter in obj.type_parameters: + _attach_parent_to_expr(type_parameter.annotation, parent) + _attach_parent_to_expr(type_parameter.default, parent) for param in obj.parameters: _attach_parent_to_expr(param.annotation, parent) _attach_parent_to_expr(param.default, parent) @@ -154,6 +172,13 @@ def _attach_parent_to_exprs(obj: Class | Function | Attribute, parent: Module | if obj.docstring: _attach_parent_to_expr(obj.docstring.value, parent) _attach_parent_to_expr(obj.value, parent) + elif isinstance(obj, TypeAlias): + if obj.docstring: + _attach_parent_to_expr(obj.docstring.value, parent) + for type_parameter in obj.type_parameters: + _attach_parent_to_expr(type_parameter.annotation, parent) + _attach_parent_to_expr(type_parameter.default, parent) + _attach_parent_to_expr(obj.value, parent) def _load_module(obj_dict: dict[str, Any]) -> Module: @@ -178,6 +203,7 @@ def _load_class(obj_dict: dict[str, Any]) -> Class: endlineno=obj_dict.get("endlineno"), docstring=_load_docstring(obj_dict), decorators=_load_decorators(obj_dict), + type_parameters=TypeParameters(*obj_dict["type_parameters"]), bases=obj_dict["bases"], ) # YORE: Bump 2: Replace line with `members = obj_dict.get("members", {}).values()`. @@ -200,6 +226,7 @@ def _load_function(obj_dict: dict[str, Any]) -> Function: parameters=Parameters(*obj_dict["parameters"]), returns=obj_dict["returns"], decorators=_load_decorators(obj_dict), + type_parameters=TypeParameters(*obj_dict["type_parameters"]), lineno=obj_dict["lineno"], endlineno=obj_dict.get("endlineno"), docstring=_load_docstring(obj_dict), @@ -230,16 +257,30 @@ def _load_alias(obj_dict: dict[str, Any]) -> Alias: ) -_loader_map: dict[Kind, Callable[[dict[str, Any]], Module | Class | Function | Attribute | Alias]] = { +def _load_type_alias(obj_dict: dict[str, Any]) -> TypeAlias: + return TypeAlias( + name=obj_dict["name"], + value=obj_dict["value"], + type_parameters=TypeParameters(*obj_dict["type_parameters"]), + lineno=obj_dict["lineno"], + endlineno=obj_dict.get("endlineno"), + docstring=_load_docstring(obj_dict), + ) + + +_loader_map: dict[Kind, Callable[[dict[str, Any]], Object | Alias]] = { Kind.MODULE: _load_module, Kind.CLASS: _load_class, Kind.FUNCTION: _load_function, Kind.ATTRIBUTE: _load_attribute, Kind.ALIAS: _load_alias, + Kind.TYPE_ALIAS: _load_type_alias, } -def json_decoder(obj_dict: dict[str, Any]) -> dict[str, Any] | Object | Alias | Parameter | str | expressions.Expr: +def json_decoder( + obj_dict: dict[str, Any], +) -> dict[str, Any] | Object | Alias | Parameter | TypeParameter | str | expressions.Expr: """Decode dictionaries as data classes. The [`json.loads`][] method walks the tree from bottom to top. @@ -261,11 +302,15 @@ def json_decoder(obj_dict: dict[str, Any]) -> dict[str, Any] | Object | Alias | # Load objects and parameters. if "kind" in obj_dict: - try: - kind = Kind(obj_dict["kind"]) - except ValueError: + kind = obj_dict["kind"] + if kind in _loader_map: + return _loader_map[kind](obj_dict) + # YORE: EOL 3.11: Replace `.__members__.values()` with `` within line. + if kind in ParameterKind.__members__.values(): return _load_parameter(obj_dict) - return _loader_map[kind](obj_dict) + # YORE: EOL 3.11: Replace `.__members__.values()` with `` within line. + if kind in TypeParameterKind.__members__.values(): + return _load_type_parameter(obj_dict) # Return dict as is. return obj_dict diff --git a/src/_griffe/enumerations.py b/src/_griffe/enumerations.py index 3a36b3b14..cbd54d230 100644 --- a/src/_griffe/enumerations.py +++ b/src/_griffe/enumerations.py @@ -33,6 +33,8 @@ class DocstringSectionKind(str, Enum): """Parameters section.""" other_parameters = "other parameters" """Other parameters (keyword arguments) section.""" + type_parameters = "type parameters" + """Type parameters section.""" raises = "raises" """Raises (exceptions) section.""" warns = "warns" @@ -51,6 +53,8 @@ class DocstringSectionKind(str, Enum): """Functions section.""" classes = "classes" """Classes section.""" + type_aliases = "type aliases" + """Type aliases section.""" modules = "modules" """Modules section.""" deprecated = "deprecated" @@ -74,6 +78,17 @@ class ParameterKind(str, Enum): """Variadic keyword parameter.""" +class TypeParameterKind(str, Enum): + """Enumeration of the different type parameter kinds.""" + + type_var = "type-var" + """Type variable.""" + type_var_tuple = "type-var-tuple" + """Type variable tuple.""" + param_spec = "param-spec" + """Parameter specification variable.""" + + class Kind(str, Enum): """Enumeration of the different object kinds.""" @@ -87,6 +102,8 @@ class Kind(str, Enum): """Attributes and properties.""" ALIAS = "alias" """Aliases (imported objects).""" + TYPE_ALIAS = "type alias" + """Type aliases.""" class ExplanationStyle(str, Enum): @@ -175,6 +192,8 @@ class ObjectKind(str, Enum): """Cached properties.""" PROPERTY = "property" """Properties.""" + TYPE_ALIAS = "type_alias" + """Type aliases.""" ATTRIBUTE = "attribute" """Attributes.""" diff --git a/src/_griffe/extensions/base.py b/src/_griffe/extensions/base.py index 483238776..2bb251fa8 100644 --- a/src/_griffe/extensions/base.py +++ b/src/_griffe/extensions/base.py @@ -22,7 +22,7 @@ from _griffe.agents.nodes.runtime import ObjectNode from _griffe.agents.visitor import Visitor from _griffe.loader import GriffeLoader - from _griffe.models import Alias, Attribute, Class, Function, Module, Object + from _griffe.models import Alias, Attribute, Class, Function, Module, Object, TypeAlias class Extension: @@ -235,6 +235,32 @@ def on_attribute_instance( **kwargs: For forward-compatibility. """ + def on_type_alias_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None: + """Run when visiting a new type alias node during static/dynamic analysis. + + Parameters: + node: The currently visited node. + agent: The analysis agent currently running. + **kwargs: For forward-compatibility. + """ + + def on_type_alias_instance( + self, + *, + node: ast.AST | ObjectNode, + type_alias: TypeAlias, + agent: Visitor | Inspector, + **kwargs: Any, + ) -> None: + """Run when a TypeAlias has been created. + + Parameters: + node: The currently visited node. + type_alias: The type alias instance. + agent: The analysis agent currently running. + **kwargs: For forward-compatibility. + """ + def on_alias( self, *, diff --git a/src/_griffe/importer.py b/src/_griffe/importer.py index aac5f8cdb..09d0a06f2 100644 --- a/src/_griffe/importer.py +++ b/src/_griffe/importer.py @@ -43,7 +43,7 @@ def sys_path(*paths: str | Path) -> Iterator[None]: def dynamic_import(import_path: str, import_paths: Sequence[str | Path] | None = None) -> Any: """Dynamically import the specified object. - It can be a module, class, method, function, attribute, + It can be a module, class, method, function, attribute, type alias, nested arbitrarily. It works like this: diff --git a/src/_griffe/loader.py b/src/_griffe/loader.py index 0766d006c..7858af7f0 100644 --- a/src/_griffe/loader.py +++ b/src/_griffe/loader.py @@ -742,8 +742,8 @@ def load( The extracted information is stored in a collection of modules, which can be queried later. Each collected module is a tree of objects, representing the structure of the module. See the [`Module`][griffe.Module], [`Class`][griffe.Class], - [`Function`][griffe.Function], and [`Attribute`][griffe.Attribute] classes - for more information. + [`Function`][griffe.Function], [`Attribute`][griffe.Attribute], and + [`TypeAlias`][griffe.TypeAlias] classes for more information. The main class used to load modules is [`GriffeLoader`][griffe.GriffeLoader]. Convenience functions like this one and [`load_git`][griffe.load_git] are also available. diff --git a/src/_griffe/merger.py b/src/_griffe/merger.py index 02d984efc..205d232ed 100644 --- a/src/_griffe/merger.py +++ b/src/_griffe/merger.py @@ -9,7 +9,7 @@ from _griffe.logger import logger if TYPE_CHECKING: - from _griffe.models import Attribute, Class, Function, Module, Object + from _griffe.models import Attribute, Class, Function, Module, Object, TypeAlias def _merge_module_stubs(module: Module, stubs: Module) -> None: @@ -21,6 +21,7 @@ def _merge_module_stubs(module: Module, stubs: Module) -> None: def _merge_class_stubs(class_: Class, stubs: Class) -> None: _merge_stubs_docstring(class_, stubs) _merge_stubs_overloads(class_, stubs) + _merge_stubs_type_parameters(class_, stubs) _merge_stubs_members(class_, stubs) @@ -30,6 +31,7 @@ def _merge_function_stubs(function: Function, stubs: Function) -> None: with suppress(KeyError): function.parameters[parameter.name].annotation = parameter.annotation function.returns = stubs.returns + _merge_stubs_type_parameters(function, stubs) def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute) -> None: @@ -37,11 +39,21 @@ def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute) -> None: attribute.annotation = stubs.annotation +def _merge_type_alias_stubs(type_alias: TypeAlias, stubs: TypeAlias) -> None: + _merge_stubs_docstring(type_alias, stubs) + _merge_stubs_type_parameters(type_alias, stubs) + + def _merge_stubs_docstring(obj: Object, stubs: Object) -> None: if not obj.docstring and stubs.docstring: obj.docstring = stubs.docstring +def _merge_stubs_type_parameters(obj: Class | Function | TypeAlias, stubs: Class | Function | TypeAlias) -> None: + if not obj.type_parameters and stubs.type_parameters: + obj.type_parameters = stubs.type_parameters + + def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None: for function_name, overloads in list(stubs.overloads.items()): if overloads: @@ -79,6 +91,8 @@ def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: _merge_function_stubs(obj_member, stub_member) # type: ignore[arg-type] elif obj_member.is_attribute: _merge_attribute_stubs(obj_member, stub_member) # type: ignore[arg-type] + elif obj_member.is_type_alias: + _merge_type_alias_stubs(obj_member, stub_member) # type: ignore[arg-type] else: stub_member.runtime = False obj.set_member(member_name, stub_member) diff --git a/src/_griffe/mixins.py b/src/_griffe/mixins.py index a7059ce73..c1992d7cc 100644 --- a/src/_griffe/mixins.py +++ b/src/_griffe/mixins.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from _griffe.models import Alias, Attribute, Class, Function, Module, Object + from _griffe.models import Alias, Attribute, Class, Function, Module, Object, TypeAlias _ObjType = TypeVar("_ObjType") @@ -279,6 +279,7 @@ class ObjectAliasMixin(GetMembersMixin, SetMembersMixin, DelMembersMixin, Serial classes: The class members. functions: The function members. attributes: The attribute members. + type_aliases: The type alias members. is_private: Whether this object/alias is private (starts with `_`) but not special. is_class_private: Whether this object/alias is class-private (starts with `__` and is a class member). is_special: Whether this object/alias is special ("dunder" attribute/method, starts and end with `__`). @@ -336,6 +337,15 @@ def attributes(self) -> dict[str, Attribute]: """ return {name: member for name, member in self.all_members.items() if member.kind is Kind.ATTRIBUTE} # type: ignore[misc] + @property + def type_aliases(self) -> dict[str, TypeAlias]: + """The type alias members. + + This method is part of the consumer API: + do not use when producing Griffe trees! + """ + return {name: member for name, member in self.all_members.items() if member.kind is Kind.TYPE_ALIAS} # type: ignore[misc] + @property def is_private(self) -> bool: """Whether this object/alias is private (starts with `_`) but not special.""" @@ -443,3 +453,8 @@ def is_deprecated(self) -> bool: """Whether this object is deprecated.""" # NOTE: We might want to add more ways to detect deprecations in the future. return bool(self.deprecated) # type: ignore[attr-defined] + + @property + def is_generic(self) -> bool: + """Whether this object is generic.""" + return bool(self.type_parameters) # type: ignore[attr-defined] diff --git a/src/_griffe/models.py b/src/_griffe/models.py index d13cd9ad9..ec7f4ec50 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -12,9 +12,9 @@ from _griffe.c3linear import c3linear_merge from _griffe.docstrings.parsers import DocstringStyle, parse -from _griffe.enumerations import Kind, ParameterKind, Parser +from _griffe.enumerations import Kind, ParameterKind, Parser, TypeParameterKind from _griffe.exceptions import AliasResolutionError, BuiltinModuleError, CyclicAliasError, NameResolutionError -from _griffe.expressions import ExprCall, ExprName +from _griffe.expressions import ExprCall, ExprName, ExprTuple from _griffe.logger import logger from _griffe.mixins import ObjectAliasMixin @@ -25,6 +25,7 @@ from _griffe.docstrings.models import DocstringSection from _griffe.expressions import Expr + from functools import cached_property @@ -370,6 +371,186 @@ def add(self, parameter: Parameter) -> None: self._params.append(parameter) +class TypeParameter: + """This class represents a type parameter.""" + + def __init__( + self, + name: str, + *, + kind: TypeParameterKind, + bound: str | Expr | None = None, + constraints: Sequence[str | Expr] | None = None, + default: str | Expr | None = None, + ) -> None: + """Initialize the type parameter. + + Parameters: + name: The type parameter name, without leading stars (`*` or `**`). + kind: The type parameter kind. + bound: The type parameter bound, if any. + Mutually exclusive with `constraints`. + constraints: The type parameter constraints, if any. + Mutually exclusive with `bound`. + default: The type parameter default, if any. + + Raises: + ValueError: When more than one of `bound` and `constraints` is set. + """ + if bound is not None and constraints: + raise ValueError("bound and constraints are mutually exclusive") + + self.name: str = name + """The type parameter name.""" + + self.kind: TypeParameterKind = kind + """The type parameter kind.""" + + self.annotation: str | Expr | None + """The type parameter bound or constraints.""" + + if constraints: + self.constraints = constraints # type: ignore[assignment] + else: + self.bound = bound + + self.default: str | Expr | None = default + """The type parameter default value.""" + + def __repr__(self) -> str: + return f"TypeParameter(name={self.name!r}, kind={self.kind!r}, bound={self.annotation!r}, default={self.default!r})" + + @property + def bound(self) -> str | Expr | None: + """The type parameter bound.""" + if not isinstance(self.annotation, ExprTuple): + return self.annotation + return None + + @bound.setter + def bound(self, bound: str | Expr | None) -> None: + self.annotation = bound + + @property + def constraints(self) -> tuple[str | Expr, ...] | None: + """The type parameter constraints.""" + if isinstance(self.annotation, ExprTuple): + return tuple(self.annotation.elements) + return None + + @constraints.setter + def constraints(self, constraints: Sequence[str | Expr] | None) -> None: + if constraints is not None: + self.annotation = ExprTuple(constraints) + else: + self.annotation = None + + def as_dict(self, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002 + """Return this type parameter's data as a dictionary. + + Parameters: + **kwargs: Additional serialization options. + + Returns: + A dictionary. + """ + base: dict[str, Any] = { + "name": self.name, + "kind": self.kind, + "annotation": self.annotation, + "default": self.default, + } + return base + + +class TypeParameters: + """This class is a container for type parameters. + + It allows to get type parameters using their position (index) or their name: + + ```pycon + >>> type_parameters = TypeParameters(TypeParameter("hello"), kind=TypeParameterKind.type_var) + >>> type_parameters[0] is type_parameters["hello"] + True + ``` + """ + + def __init__(self, *type_parameters: TypeParameter) -> None: + """Initialize the type parameters container. + + Parameters: + *type_parameters: The initial type parameters to add to the container. + """ + self._type_params: list[TypeParameter] = list(type_parameters) + + def __repr__(self) -> str: + return f"TypeParameters({', '.join(repr(type_param) for type_param in self._type_params)})" + + def __getitem__(self, name_or_index: int | str) -> TypeParameter: + """Get a type parameter by index or name.""" + if isinstance(name_or_index, int): + return self._type_params[name_or_index] + name = name_or_index.lstrip("*") + try: + return next(param for param in self._type_params if param.name == name) + except StopIteration as error: + raise KeyError(f"type parameter {name_or_index} not found") from error + + def __setitem__(self, name_or_index: int | str, type_parameter: TypeParameter) -> None: + """Set a type parameter by index or name.""" + if isinstance(name_or_index, int): + self._type_params[name_or_index] = type_parameter + else: + name = name_or_index.lstrip("*") + try: + index = next(idx for idx, param in enumerate(self._type_params) if param.name == name) + except StopIteration: + self._type_params.append(type_parameter) + else: + self._type_params[index] = type_parameter + + def __delitem__(self, name_or_index: int | str) -> None: + """Delete a type parameter by index or name.""" + if isinstance(name_or_index, int): + del self._type_params[name_or_index] + else: + name = name_or_index.lstrip("*") + try: + index = next(idx for idx, param in enumerate(self._type_params) if param.name == name) + except StopIteration as error: + raise KeyError(f"type parameter {name_or_index} not found") from error + del self._type_params[index] + + def __len__(self): + """The number of type parameters.""" + return len(self._type_params) + + def __iter__(self): + """Iterate over the type parameters, in order.""" + return iter(self._type_params) + + def __contains__(self, type_param_name: str): + """Whether a type parameter with the given name is present.""" + try: + next(param for param in self._type_params if param.name == type_param_name.lstrip("*")) + except StopIteration: + return False + return True + + def add(self, type_parameter: TypeParameter) -> None: + """Add a type parameter to the container. + + Parameters: + type_parameter: The function parameter to add. + + Raises: + ValueError: When a type parameter with the same name is already present. + """ + if type_parameter.name in self: + raise ValueError(f"type parameter {type_parameter.name} already present") + self._type_params.append(type_parameter) + + class Object(ObjectAliasMixin): """An abstract class representing a Python object.""" @@ -393,6 +574,7 @@ def __init__( endlineno: int | None = None, runtime: bool = True, docstring: Docstring | None = None, + type_parameters: TypeParameters | None = None, parent: Module | Class | None = None, lines_collection: LinesCollection | None = None, modules_collection: ModulesCollection | None = None, @@ -405,6 +587,7 @@ def __init__( endlineno: The object ending line (inclusive), or None for modules. runtime: Whether this object is present at runtime or not. docstring: The object docstring. + type_parameters: The object type parameters, if any. parent: The object parent. lines_collection: A collection of source code lines. modules_collection: A collection of modules. @@ -431,11 +614,14 @@ def __init__( [`has_docstrings`][griffe.Object.has_docstrings]. """ + self.type_parameters: TypeParameters = type_parameters or TypeParameters() + """The object type parameters.""" + self.parent: Module | Class | None = parent """The parent of the object (none if top module).""" self.members: dict[str, Object | Alias] = {} - """The object members (modules, classes, functions, attributes). + """The object members (modules, classes, functions, attributes, type aliases). See also: [`inherited_members`][griffe.Object.inherited_members], [`get_member`][griffe.Object.get_member], @@ -554,6 +740,7 @@ def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool: [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], + [`is_type_alias`][griffe.Object.is_type_alias], [`is_alias`][griffe.Object.is_alias]. Parameters: @@ -604,6 +791,7 @@ def is_module(self) -> bool: [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], + [`is_type_alias`][griffe.Object.is_type_alias], [`is_alias`][griffe.Object.is_alias], [`is_kind`][griffe.Object.is_kind]. """ @@ -616,6 +804,7 @@ def is_class(self) -> bool: See also: [`is_module`][griffe.Object.is_module]. [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], + [`is_type_alias`][griffe.Object.is_type_alias], [`is_alias`][griffe.Object.is_alias], [`is_kind`][griffe.Object.is_kind]. """ @@ -628,6 +817,7 @@ def is_function(self) -> bool: See also: [`is_module`][griffe.Object.is_module]. [`is_class`][griffe.Object.is_class], [`is_attribute`][griffe.Object.is_attribute], + [`is_type_alias`][griffe.Object.is_type_alias], [`is_alias`][griffe.Object.is_alias], [`is_kind`][griffe.Object.is_kind]. """ @@ -640,11 +830,25 @@ def is_attribute(self) -> bool: See also: [`is_module`][griffe.Object.is_module]. [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], + [`is_type_alias`][griffe.Object.is_type_alias], [`is_alias`][griffe.Object.is_alias], [`is_kind`][griffe.Object.is_kind]. """ return self.kind is Kind.ATTRIBUTE + @property + def is_type_alias(self) -> bool: + """Whether this object is a type alias. + + See also: [`is_module`][griffe.Object.is_module]. + [`is_class`][griffe.Object.is_class], + [`is_function`][griffe.Object.is_function], + [`is_attribute`][griffe.Object.is_attribute], + [`is_alias`][griffe.Object.is_alias], + [`is_kind`][griffe.Object.is_kind]. + """ + return self.kind is Kind.TYPE_ALIAS + @property def is_init_module(self) -> bool: """Whether this object is an `__init__.py` module. @@ -994,6 +1198,8 @@ def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: base["endlineno"] = self.endlineno if self.docstring: base["docstring"] = self.docstring + if self.type_parameters: + base["type_parameters"] = [type_param.as_dict(**kwargs) for type_param in self.type_parameters] base["labels"] = self.labels base["members"] = {name: member.as_dict(full=full, **kwargs) for name, member in self.members.items()} @@ -1160,7 +1366,7 @@ def modules_collection(self) -> ModulesCollection: @property def members(self) -> dict[str, Object | Alias]: - """The target's members (modules, classes, functions, attributes). + """The target's members (modules, classes, functions, attributes, type aliases). See also: [`inherited_members`][griffe.Alias.inherited_members], [`get_member`][griffe.Alias.get_member], @@ -1257,6 +1463,11 @@ def docstring(self) -> Docstring | None: def docstring(self, docstring: Docstring | None) -> None: self.final_target.docstring = docstring + @property + def type_parameters(self) -> TypeParameters: + """The target type parameters.""" + return self.final_target.type_parameters + @property def labels(self) -> set[str]: """The target labels (`property`, `dataclass`, etc.). @@ -1307,6 +1518,7 @@ def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool: [`is_class`][griffe.Alias.is_class], [`is_function`][griffe.Alias.is_function], [`is_attribute`][griffe.Alias.is_attribute], + [`is_type_alias`][griffe.Alias.is_type_alias], [`is_alias`][griffe.Alias.is_alias]. Parameters: @@ -1328,6 +1540,7 @@ def is_module(self) -> bool: [`is_class`][griffe.Alias.is_class], [`is_function`][griffe.Alias.is_function], [`is_attribute`][griffe.Alias.is_attribute], + [`is_type_alias`][griffe.Alias.is_type_alias], [`is_alias`][griffe.Alias.is_alias], [`is_kind`][griffe.Alias.is_kind]. """ @@ -1340,6 +1553,7 @@ def is_class(self) -> bool: See also: [`is_module`][griffe.Alias.is_module], [`is_function`][griffe.Alias.is_function], [`is_attribute`][griffe.Alias.is_attribute], + [`is_type_alias`][griffe.Alias.is_type_alias], [`is_alias`][griffe.Alias.is_alias], [`is_kind`][griffe.Alias.is_kind]. """ @@ -1352,6 +1566,7 @@ def is_function(self) -> bool: See also: [`is_module`][griffe.Alias.is_module], [`is_class`][griffe.Alias.is_class], [`is_attribute`][griffe.Alias.is_attribute], + [`is_type_alias`][griffe.Alias.is_type_alias], [`is_alias`][griffe.Alias.is_alias], [`is_kind`][griffe.Alias.is_kind]. """ @@ -1364,11 +1579,25 @@ def is_attribute(self) -> bool: See also: [`is_module`][griffe.Alias.is_module], [`is_class`][griffe.Alias.is_class], [`is_function`][griffe.Alias.is_function], + [`is_type_alias`][griffe.Alias.is_type_alias], [`is_alias`][griffe.Alias.is_alias], [`is_kind`][griffe.Alias.is_kind]. """ return self.final_target.is_attribute + @property + def is_type_alias(self) -> bool: + """Whether this object is a type alias. + + See also: [`is_module`][griffe.Alias.is_module], + [`is_class`][griffe.Alias.is_class], + [`is_function`][griffe.Alias.is_function], + [`is_attribute`][griffe.Alias.is_attribute], + [`is_alias`][griffe.Alias.is_alias], + [`is_kind`][griffe.Alias.is_kind]. + """ + return self.final_target.is_type_alias + def has_labels(self, *labels: str) -> bool: """Tell if this object has all the given labels. @@ -1505,7 +1734,7 @@ def resolve(self, name: str) -> str: """ return self.final_target.resolve(name) - # SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE PROXIES --------------- + # SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE/TYPE ALIAS PROXIES --------------- # These methods and properties exist on targets of specific kind. # We first try to reach the final target, triggering alias resolution errors # and cyclic aliases errors early. We avoid recursing in the alias chain. @@ -1618,8 +1847,8 @@ def deleter(self) -> Function | None: @property def value(self) -> str | Expr | None: - """The attribute value.""" - return cast(Attribute, self.final_target).value + """The attribute or type alias value.""" + return cast(Union[Attribute, TypeAlias], self.final_target).value @property def annotation(self) -> str | Expr | None: @@ -2112,7 +2341,7 @@ def __init__( """The deleter linked to this property.""" def as_dict(self, **kwargs: Any) -> dict[str, Any]: - """Return this function's data as a dictionary. + """Return this attribute's data as a dictionary. See also: [`as_json`][griffe.Attribute.as_json]. @@ -2128,3 +2357,39 @@ def as_dict(self, **kwargs: Any) -> dict[str, Any]: if self.annotation is not None: base["annotation"] = self.annotation return base + + +class TypeAlias(Object): + """The class representing a Python type alias.""" + + kind = Kind.TYPE_ALIAS + + def __init__( + self, + *args: Any, + value: str | Expr | None, + **kwargs: Any, + ) -> None: + """Initialize the function. + + Parameters: + *args: See [`griffe.Object`][]. + value: The type alias value. + **kwargs: See [`griffe.Object`][]. + """ + super().__init__(*args, **kwargs, runtime=False) + self.value: str | Expr | None = value + """The type alias value.""" + + def as_dict(self, **kwargs: Any) -> dict[str, Any]: + """Return this type alias's data as a dictionary. + + Parameters: + **kwargs: Additional serialization options. + + Returns: + A dictionary. + """ + base = super().as_dict(**kwargs) + base["value"] = self.value + return base diff --git a/src/_griffe/stats.py b/src/_griffe/stats.py index 7b89c0c21..2f1df30f0 100644 --- a/src/_griffe/stats.py +++ b/src/_griffe/stats.py @@ -46,6 +46,7 @@ def __init__(self, loader: GriffeLoader) -> None: Kind.CLASS: 0, Kind.FUNCTION: 0, Kind.ATTRIBUTE: 0, + Kind.TYPE_ALIAS: 0, } """Number of objects by kind.""" @@ -94,7 +95,8 @@ def as_text(self) -> str: classes = self.by_kind[Kind.CLASS] functions = self.by_kind[Kind.FUNCTION] attributes = self.by_kind[Kind.ATTRIBUTE] - objects = sum((modules, classes, functions, attributes)) + type_aliases = self.by_kind[Kind.TYPE_ALIAS] + objects = sum((modules, classes, functions, attributes, type_aliases)) lines.append("Statistics") lines.append("---------------------") lines.append("Number of loaded objects") @@ -102,6 +104,7 @@ def as_text(self) -> str: lines.append(f" Classes: {classes}") lines.append(f" Functions: {functions}") lines.append(f" Attributes: {attributes}") + lines.append(f" Type aliases: {type_aliases}") lines.append(f" Total: {objects} across {packages} packages") per_ext = self.modules_by_extension builtin = per_ext[""] diff --git a/src/griffe/__init__.py b/src/griffe/__init__.py index 73667a0c9..381bb6675 100644 --- a/src/griffe/__init__.py +++ b/src/griffe/__init__.py @@ -257,6 +257,7 @@ ObjectKind, ParameterKind, Parser, + TypeParameterKind, ) from _griffe.exceptions import ( AliasResolutionError, @@ -347,6 +348,9 @@ Object, Parameter, Parameters, + TypeAlias, + TypeParameter, + TypeParameters, ) from _griffe.stats import Stats from _griffe.tests import ( @@ -500,6 +504,10 @@ "SetMembersMixin", "Stats", "TmpPackage", + "TypeAlias", + "TypeParameter", + "TypeParameterKind", + "TypeParameters", "UnhandledEditableModuleError", "UnimportableModuleError", "Visitor", diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 7bd80e070..832e2e1b8 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -3,11 +3,12 @@ from __future__ import annotations import json +import sys import pytest from jsonschema import ValidationError, validate -from griffe import Function, GriffeLoader, Module, Object +from griffe import Function, GriffeLoader, Module, Object, temporary_visited_module def test_minimal_data_is_enough() -> None: @@ -33,6 +34,63 @@ def test_minimal_data_is_enough() -> None: Function.from_json(minimal) +# YORE: EOL 3.12: Remove block. +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_encoding_pep695_generics_without_defaults() -> None: + """Test serialization and de-serialization of PEP 695 generics without defaults. + + Defaults are only possible from Python 3.13 onwards. + """ + with temporary_visited_module( + """ + class Class[X: Exception]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str)] = dict[str, T] + """, + ) as module: + minimal = module.as_json(full=False) + full = module.as_json(full=True) + reloaded = Module.from_json(minimal) + assert reloaded.as_json(full=False) == minimal + assert reloaded.as_json(full=True) == full + + # Also works (but will result in a different type hint). + assert Object.from_json(minimal) + + # Won't work if the JSON doesn't represent the type requested. + with pytest.raises(TypeError, match="provided JSON object is not of type"): + Function.from_json(minimal) + + +# YORE: EOL 3.12: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip +def test_encoding_pep695_generics() -> None: + """Test serialization and de-serialization of PEP 695 generics with defaults. + + Defaults are only possible from Python 3.13 onwards. + """ + with temporary_visited_module( + """ + class Class[X: Exception = OSError]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str) = str] = dict[str, T] + """, + ) as module: + minimal = module.as_json(full=False) + full = module.as_json(full=True) + reloaded = Module.from_json(minimal) + assert reloaded.as_json(full=False) == minimal + assert reloaded.as_json(full=True) == full + + # Also works (but will result in a different type hint). + assert Object.from_json(minimal) + + # Won't work if the JSON doesn't represent the type requested. + with pytest.raises(TypeError, match="provided JSON object is not of type"): + Function.from_json(minimal) + + # use this function in test_json_schema to ease schema debugging def _validate(obj: dict, schema: dict) -> None: if "members" in obj: @@ -55,3 +113,44 @@ def test_json_schema() -> None: with open("docs/schema.json") as f: # noqa: PTH123 schema = json.load(f) validate(data, schema) + + +# YORE: EOL 3.12: Remove block. +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_json_schema_for_pep695_generics_without_defaults() -> None: + """Assert that serialized PEP 695 generics without defaults match our JSON schema. + + Defaults are only possible from Python 3.13 onwards. + """ + with temporary_visited_module( + """ + class Class[X: Exception]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str)] = dict[str, T] + """, + ) as module: + data = json.loads(module.as_json(full=True)) + with open("docs/schema.json") as f: # noqa: PTH123 + schema = json.load(f) + validate(data, schema) + + +# YORE: EOL 3.12: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip +def test_json_schema_for_pep695_generics() -> None: + """Assert that serialized PEP 695 generics with defaults match our JSON schema. + + Defaults are only possible from Python 3.13 onwards. + """ + with temporary_visited_module( + """ + class Class[X: Exception = OSError]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str) = str] = dict[str, T] + """, + ) as module: + data = json.loads(module.as_json(full=True)) + with open("docs/schema.json") as f: # noqa: PTH123 + schema = json.load(f) + validate(data, schema) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index cfc184e53..2406c5595 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from pathlib import Path from typing import TYPE_CHECKING, Any @@ -12,7 +13,7 @@ if TYPE_CHECKING: import ast - from griffe import Attribute, Class, Function, Module, Object, ObjectNode + from griffe import Attribute, Class, Function, Module, Object, ObjectNode, TypeAlias class ExtensionTest(Extension): # noqa: D101 @@ -61,6 +62,12 @@ def on_module_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: def on_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002 self.records.append("on_node") + def on_type_alias_instance(self, *, node: ast.AST | ObjectNode, type_alias: TypeAlias, **kwargs: Any) -> None: # noqa: D102,ARG002 + self.records.append("on_type_alias_instance") + + def on_type_alias_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002 + self.records.append("on_type_alias_node") + @pytest.mark.parametrize( "extension", @@ -102,6 +109,41 @@ def test_loading_extensions(extension: str | dict[str, dict[str, Any]] | Extensi assert loaded.kwargs == {"option": 0} +# YORE: EOL 3.11: Remove block. +def test_extension_events_without_type_aliases() -> 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) + + +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 type aliases") def test_extension_events() -> None: """Test events triggering.""" extension = ExtensionTest() @@ -112,6 +154,7 @@ def func(): ... class Class: cattr = 1 def method(self): ... + type TypeAlias = list[int] """, extensions=load_extensions(extension), ): @@ -130,5 +173,7 @@ def method(self): ... "on_module_members", "on_module_node", "on_node", + "on_type_alias_instance", + "on_type_alias_node", ] assert set(events) == set(extension.records) diff --git a/tests/test_inspector.py b/tests/test_inspector.py index 8e634a744..bfdc7007e 100644 --- a/tests/test_inspector.py +++ b/tests/test_inspector.py @@ -2,8 +2,12 @@ from __future__ import annotations +import sys + import pytest +from _griffe.enumerations import TypeParameterKind +from _griffe.expressions import Expr from griffe import inspect, temporary_inspected_module, temporary_inspected_package, temporary_pypackage from tests.helpers import clear_sys_modules @@ -151,3 +155,102 @@ def func(a: int, b: int) -> int: pass assert partial_func.parameters[0].name == "b" assert partial_func.parameters[0].annotation.name == "int" assert partial_func.returns.name == "int" + + +# YORE: EOL 3.12: Remove block. +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_inspecting_pep695_generics_without_defaults() -> None: + """Assert PEP 695 generics are correctly inspected.""" + with temporary_inspected_module( + """ + class Class[X: Exception]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str)] = dict[str, T] + """, + ) as module: + class_ = module["Class"] + assert class_.is_class + assert class_.type_parameters[0].name == "X" + assert class_.type_parameters[0].kind == TypeParameterKind.type_var + assert class_.type_parameters[0].bound.name == "Exception" + assert not class_.type_parameters[0].constraints + assert class_.type_parameters[0].default is None + + func = module["func"] + assert func.is_function + assert func.type_parameters[0].name == "P" + assert func.type_parameters[0].kind == TypeParameterKind.param_spec + assert func.type_parameters[0].bound is None + assert not func.type_parameters[0].constraints + assert func.type_parameters[0].default is None + assert func.type_parameters[1].name == "T" + assert func.type_parameters[1].kind == TypeParameterKind.type_var + assert func.type_parameters[1].bound is None + assert not func.type_parameters[1].constraints + assert func.type_parameters[1].default is None + assert func.type_parameters[2].name == "R" + assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple + assert func.type_parameters[2].bound is None + assert not func.type_parameters[2].constraints + assert func.type_parameters[2].default is None + + type_alias = module["TA"] + assert type_alias.is_type_alias + assert type_alias.type_parameters[0].name == "T" + assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var + assert type_alias.type_parameters[0].bound is None + assert type_alias.type_parameters[0].constraints[0].name == "int" + assert type_alias.type_parameters[0].constraints[1].name == "str" + assert type_alias.type_parameters[0].default is None + assert isinstance(type_alias.value, Expr) + assert str(type_alias.value) == "dict[str, T]" + + +# YORE: EOL 3.12: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip +def test_inspecting_pep695_generics() -> None: + """Assert PEP 695 generics are correctly inspected.""" + with temporary_inspected_module( + """ + class Class[X: Exception = OSError]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str) = str] = dict[str, T] + """, + ) as module: + class_ = module["Class"] + assert class_.is_class + assert class_.type_parameters[0].name == "X" + assert class_.type_parameters[0].kind == TypeParameterKind.type_var + assert class_.type_parameters[0].bound.name == "Exception" + assert not class_.type_parameters[0].constraints + assert class_.type_parameters[0].default.name == "OSError" + + func = module["func"] + assert func.is_function + assert func.type_parameters[0].name == "P" + assert func.type_parameters[0].kind == TypeParameterKind.param_spec + assert func.type_parameters[0].bound is None + assert not func.type_parameters[0].constraints + assert func.type_parameters[0].default is None + assert func.type_parameters[1].name == "T" + assert func.type_parameters[1].kind == TypeParameterKind.type_var + assert func.type_parameters[1].bound is None + assert not func.type_parameters[1].constraints + assert func.type_parameters[1].default is None + assert func.type_parameters[2].name == "R" + assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple + assert func.type_parameters[2].bound is None + assert not func.type_parameters[2].constraints + assert func.type_parameters[2].default is None + + type_alias = module["TA"] + assert type_alias.is_type_alias + assert type_alias.type_parameters[0].name == "T" + assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var + assert type_alias.type_parameters[0].bound is None + assert type_alias.type_parameters[0].constraints[0].name == "int" + assert type_alias.type_parameters[0].constraints[1].name == "str" + assert type_alias.type_parameters[0].default.name == "str" + assert isinstance(type_alias.value, Expr) + assert str(type_alias.value) == "dict[str, T]" diff --git a/tests/test_models.py b/tests/test_models.py index e86eb2602..a1b68fcb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,8 @@ import pytest +from _griffe.enumerations import TypeParameterKind +from _griffe.models import TypeParameter, TypeParameters from griffe import ( Attribute, Docstring, @@ -526,3 +528,38 @@ def test_delete_parameters() -> None: del parameters[0] assert "x" not in parameters assert len(parameters) == 0 + + +def test_set_type_parameters() -> None: + """We can set type parameters.""" + type_parameters = TypeParameters() + # Does not exist yet. + type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var) + assert "x" in type_parameters + # Already exists, by name. + type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var) + assert "x" in type_parameters + assert len(type_parameters) == 1 + # Already exists, by name, with different kind. + type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.param_spec) + assert "x" in type_parameters + assert len(type_parameters) == 1 + # Already exists, by index. + type_parameters[0] = TypeParameter(name="y", kind=TypeParameterKind.type_var) + assert "y" in type_parameters + assert len(type_parameters) == 1 + + +def test_delete_type_parameters() -> None: + """We can delete type parameters.""" + type_parameters = TypeParameters() + # By name. + type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var) + del type_parameters["x"] + assert "x" not in type_parameters + assert len(type_parameters) == 0 + # By index. + type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var) + del type_parameters[0] + assert "x" not in type_parameters + assert len(type_parameters) == 0 diff --git a/tests/test_visitor.py b/tests/test_visitor.py index c2b6a557a..988940eac 100644 --- a/tests/test_visitor.py +++ b/tests/test_visitor.py @@ -2,10 +2,13 @@ from __future__ import annotations +import sys from textwrap import dedent import pytest +from _griffe.enumerations import TypeParameterKind +from _griffe.expressions import Expr from griffe import GriffeLoader, temporary_pypackage, temporary_visited_module, temporary_visited_package @@ -385,3 +388,102 @@ def test_parse_deep_attributes_in__all__() -> None: }, ) as package: assert "hello" in package.exports # type: ignore[operator] + + +# YORE: EOL 3.12: Remove block. +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_parse_pep695_generics_without_defaults() -> None: + """Assert PEP 695 generics are correctly inspected.""" + with temporary_visited_module( + """ + class Class[X: Exception]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str)] = dict[str, T] + """, + ) as module: + class_ = module["Class"] + assert class_.is_class + assert class_.type_parameters[0].name == "X" + assert class_.type_parameters[0].kind == TypeParameterKind.type_var + assert class_.type_parameters[0].bound.name == "Exception" + assert not class_.type_parameters[0].constraints + assert class_.type_parameters[0].default is None + + func = module["func"] + assert func.is_function + assert func.type_parameters[0].name == "P" + assert func.type_parameters[0].kind == TypeParameterKind.param_spec + assert func.type_parameters[0].bound is None + assert not func.type_parameters[0].constraints + assert func.type_parameters[0].default is None + assert func.type_parameters[1].name == "T" + assert func.type_parameters[1].kind == TypeParameterKind.type_var + assert func.type_parameters[1].bound is None + assert not func.type_parameters[1].constraints + assert func.type_parameters[1].default is None + assert func.type_parameters[2].name == "R" + assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple + assert func.type_parameters[2].bound is None + assert not func.type_parameters[2].constraints + assert func.type_parameters[2].default is None + + type_alias = module["TA"] + assert type_alias.is_type_alias + assert type_alias.type_parameters[0].name == "T" + assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var + assert type_alias.type_parameters[0].bound is None + assert type_alias.type_parameters[0].constraints[0].name == "int" + assert type_alias.type_parameters[0].constraints[1].name == "str" + assert type_alias.type_parameters[0].default is None + assert isinstance(type_alias.value, Expr) + assert str(type_alias.value) == "dict[str, T]" + + +# YORE: EOL 3.12: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip +def test_parse_pep695_generics() -> None: + """Assert PEP 695 generics are correctly parsed.""" + with temporary_visited_module( + """ + class Class[X: Exception = OSError]: pass + def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass + type TA[T: (int, str) = str] = dict[str, T] + """, + ) as module: + class_ = module["Class"] + assert class_.is_class + assert class_.type_parameters[0].name == "X" + assert class_.type_parameters[0].kind == TypeParameterKind.type_var + assert class_.type_parameters[0].bound.name == "Exception" + assert not class_.type_parameters[0].constraints + assert class_.type_parameters[0].default.name == "OSError" + + func = module["func"] + assert func.is_function + assert func.type_parameters[0].name == "P" + assert func.type_parameters[0].kind == TypeParameterKind.param_spec + assert func.type_parameters[0].bound is None + assert not func.type_parameters[0].constraints + assert func.type_parameters[0].default is None + assert func.type_parameters[1].name == "T" + assert func.type_parameters[1].kind == TypeParameterKind.type_var + assert func.type_parameters[1].bound is None + assert not func.type_parameters[1].constraints + assert func.type_parameters[1].default is None + assert func.type_parameters[2].name == "R" + assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple + assert func.type_parameters[2].bound is None + assert not func.type_parameters[2].constraints + assert func.type_parameters[2].default is None + + type_alias = module["TA"] + assert type_alias.is_type_alias + assert type_alias.type_parameters[0].name == "T" + assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var + assert type_alias.type_parameters[0].bound is None + assert type_alias.type_parameters[0].constraints[0].name == "int" + assert type_alias.type_parameters[0].constraints[1].name == "str" + assert type_alias.type_parameters[0].default.name == "str" + assert isinstance(type_alias.value, Expr) + assert str(type_alias.value) == "dict[str, T]" From 4d2c744f7a139e2f107f07b81e4fcf4a2cd1d417 Mon Sep 17 00:00:00 2001 From: Victor Westerhuis Date: Sat, 28 Dec 2024 16:35:28 +0100 Subject: [PATCH 2/8] Add support for PEP 695 to docstring parsers --- docs/reference/api/docstrings/models.md | 8 ++ src/_griffe/docstrings/google.py | 105 ++++++++++++++++ src/_griffe/docstrings/models.py | 82 ++++++++++++- src/_griffe/docstrings/numpy.py | 98 +++++++++++++++ src/griffe/__init__.py | 8 ++ tests/test_docstrings/test_google.py | 154 ++++++++++++++++++++++++ 6 files changed, 452 insertions(+), 3 deletions(-) diff --git a/docs/reference/api/docstrings/models.md b/docs/reference/api/docstrings/models.md index 5cb05e09c..17f3bdfd9 100644 --- a/docs/reference/api/docstrings/models.md +++ b/docs/reference/api/docstrings/models.md @@ -14,6 +14,8 @@ ::: griffe.DocstringSectionOtherParameters +::: griffe.DocstringSectionTypeParameters + ::: griffe.DocstringSectionRaises ::: griffe.DocstringSectionWarns @@ -32,6 +34,8 @@ ::: griffe.DocstringSectionClasses +::: griffe.DocstringSectionTypeAliases + ::: griffe.DocstringSectionModules ::: griffe.DocstringSectionDeprecated @@ -56,12 +60,16 @@ ::: griffe.DocstringParameter +::: griffe.DocstringTypeParameter + ::: griffe.DocstringAttribute ::: griffe.DocstringFunction ::: griffe.DocstringClass +::: griffe.DocstringTypeAlias + ::: griffe.DocstringModule ## **Models base classes** diff --git a/src/_griffe/docstrings/google.py b/src/_griffe/docstrings/google.py index e8defadf5..c086a6344 100644 --- a/src/_griffe/docstrings/google.py +++ b/src/_griffe/docstrings/google.py @@ -29,8 +29,12 @@ DocstringSectionReceives, DocstringSectionReturns, DocstringSectionText, + DocstringSectionTypeAliases, + DocstringSectionTypeParameters, DocstringSectionWarns, DocstringSectionYields, + DocstringTypeAlias, + DocstringTypeParameter, DocstringWarn, DocstringYield, ) @@ -56,6 +60,10 @@ "other arguments": DocstringSectionKind.other_parameters, "other params": DocstringSectionKind.other_parameters, "other parameters": DocstringSectionKind.other_parameters, + "type args": DocstringSectionKind.type_parameters, + "type arguments": DocstringSectionKind.type_parameters, + "type params": DocstringSectionKind.type_parameters, + "type parameters": DocstringSectionKind.type_parameters, "raises": DocstringSectionKind.raises, "exceptions": DocstringSectionKind.raises, "returns": DocstringSectionKind.returns, @@ -66,6 +74,7 @@ "functions": DocstringSectionKind.functions, "methods": DocstringSectionKind.functions, "classes": DocstringSectionKind.classes, + "type aliases": DocstringSectionKind.type_aliases, "modules": DocstringSectionKind.modules, "warns": DocstringSectionKind.warns, "warnings": DocstringSectionKind.warns, @@ -258,6 +267,75 @@ def _read_other_parameters_section( return DocstringSectionOtherParameters(parameters), new_offset +def _read_type_parameters_section( + docstring: Docstring, + *, + offset: int, + warn_unknown_params: bool = True, + **options: Any, +) -> tuple[DocstringSectionTypeParameters | None, int]: + type_parameters = [] + bound: str | Expr | None + + block, new_offset = _read_block_items(docstring, offset=offset, **options) + + for line_number, type_param_lines in block: + # check the presence of a name and description, separated by a colon + try: + name_with_bound, description = type_param_lines[0].split(":", 1) + except ValueError: + docstring_warning( + docstring, + line_number, + f"Failed to get 'name: description' pair from '{type_param_lines[0]}'", + ) + continue + + description = "\n".join([description.lstrip(), *type_param_lines[1:]]).rstrip("\n") + + # use the type given after the type parameter name, if any + if " " in name_with_bound: + name, bound = name_with_bound.split(" ", 1) + if bound.startswith("(") and bound.endswith(")"): + bound = bound[1:-1] + # try to compile the annotation to transform it into an expression + bound = parse_docstring_annotation(bound, docstring) + else: + name = name_with_bound + # try to use the annotation from the signature + try: + bound = docstring.parent.type_parameters[name].annotation # type: ignore[union-attr] + except (AttributeError, KeyError): + bound = None + + try: + default = docstring.parent.type_parameters[name].default # type: ignore[union-attr] + except (AttributeError, KeyError): + default = None + + if warn_unknown_params: + with suppress(AttributeError): # for type parameters sections in objects without type parameters + type_params = docstring.parent.type_parameters # type: ignore[union-attr] + if name not in type_params: + message = f"Type parameter '{name}' does not appear in the {docstring.parent.kind.value} signature" # type: ignore[union-attr] + for starred_name in (f"*{name}", f"**{name}"): + if starred_name in type_params: + message += f". Did you mean '{starred_name}'?" + break + docstring_warning(docstring, line_number, message) + + type_parameters.append( + DocstringTypeParameter( + name=name, + value=default, + annotation=bound, + description=description, + ), + ) + + return DocstringSectionTypeParameters(type_parameters), new_offset + + def _read_attributes_section( docstring: Docstring, *, @@ -365,6 +443,31 @@ def _read_classes_section( return DocstringSectionClasses(classes), new_offset +def _read_type_aliases_section( + docstring: Docstring, + *, + offset: int, + **options: Any, +) -> tuple[DocstringSectionTypeAliases | None, int]: + type_aliases = [] + block, new_offset = _read_block_items(docstring, offset=offset, **options) + + for line_number, type_alias_lines in block: + try: + name, description = type_alias_lines[0].split(":", 1) + except ValueError: + docstring_warning( + docstring, + line_number, + f"Failed to get 'name: description' pair from '{type_alias_lines[0]}'", + ) + continue + description = "\n".join([description.lstrip(), *type_alias_lines[1:]]).rstrip("\n") + type_aliases.append(DocstringTypeAlias(name=name, description=description)) + + return DocstringSectionTypeAliases(type_aliases), new_offset + + def _read_modules_section( docstring: Docstring, *, @@ -726,12 +829,14 @@ def _is_empty_line(line: str) -> bool: _section_reader = { DocstringSectionKind.parameters: _read_parameters_section, DocstringSectionKind.other_parameters: _read_other_parameters_section, + DocstringSectionKind.type_parameters: _read_type_parameters_section, DocstringSectionKind.raises: _read_raises_section, DocstringSectionKind.warns: _read_warns_section, DocstringSectionKind.examples: _read_examples_section, DocstringSectionKind.attributes: _read_attributes_section, DocstringSectionKind.functions: _read_functions_section, DocstringSectionKind.classes: _read_classes_section, + DocstringSectionKind.type_aliases: _read_type_aliases_section, DocstringSectionKind.modules: _read_modules_section, DocstringSectionKind.returns: _read_returns_section, DocstringSectionKind.yields: _read_yields_section, diff --git a/src/_griffe/docstrings/models.py b/src/_griffe/docstrings/models.py index 3aeadbe80..2d6e27282 100644 --- a/src/_griffe/docstrings/models.py +++ b/src/_griffe/docstrings/models.py @@ -5,8 +5,10 @@ from typing import TYPE_CHECKING from _griffe.enumerations import DocstringSectionKind +from _griffe.expressions import ExprTuple if TYPE_CHECKING: + from collections.abc import Sequence from typing import Any, Literal from _griffe.expressions import Expr @@ -52,7 +54,7 @@ def __init__( *, description: str, annotation: str | Expr | None = None, - value: str | None = None, + value: str | Expr | None = None, ) -> None: """Initialize the element. @@ -65,7 +67,7 @@ def __init__( super().__init__(description=description, annotation=annotation) self.name: str = name """The element name.""" - self.value: str | None = value + self.value: str | Expr | None = value """The element value, if any""" def as_dict(self, **kwargs: Any) -> dict[str, Any]: @@ -142,7 +144,7 @@ class DocstringParameter(DocstringNamedElement): """This class represent a documented function parameter.""" @property - def default(self) -> str | None: + def default(self) -> str | Expr | None: """The default value of this parameter.""" return self.value @@ -151,6 +153,44 @@ def default(self, value: str) -> None: self.value = value +class DocstringTypeParameter(DocstringNamedElement): + """This class represent a documented type parameter.""" + + @property + def default(self) -> str | Expr | None: + """The default value of this type parameter.""" + return self.value + + @default.setter + def default(self, value: str) -> None: + self.value = value + + @property + def bound(self) -> str | Expr | None: + """The bound of this type parameter.""" + if not isinstance(self.annotation, ExprTuple): + return self.annotation + return None + + @bound.setter + def bound(self, bound: str | Expr | None) -> None: + self.annotation = bound + + @property + def constraints(self) -> tuple[str | Expr, ...] | None: + """The constraints of this type parameter.""" + if isinstance(self.annotation, ExprTuple): + return tuple(self.annotation.elements) + return None + + @constraints.setter + def constraints(self, constraints: Sequence[str | Expr] | None) -> None: + if constraints is not None: + self.annotation = ExprTuple(constraints) + else: + self.annotation = None + + class DocstringAttribute(DocstringNamedElement): """This class represents a documented module/class attribute.""" @@ -173,6 +213,10 @@ def signature(self) -> str | Expr | None: return self.annotation +class DocstringTypeAlias(DocstringNamedElement): + """This class represents a documented type alias.""" + + class DocstringModule(DocstringNamedElement): """This class represents a documented module.""" @@ -256,6 +300,22 @@ class DocstringSectionOtherParameters(DocstringSectionParameters): kind: DocstringSectionKind = DocstringSectionKind.other_parameters +class DocstringSectionTypeParameters(DocstringSection): + """This class represents a type parameters section.""" + + kind: DocstringSectionKind = DocstringSectionKind.type_parameters + + def __init__(self, value: list[DocstringTypeParameter], title: str | None = None) -> None: + """Initialize the section. + + Parameters: + value: The section type parameters. + title: An optional title. + """ + super().__init__(title) + self.value: list[DocstringTypeParameter] = value + + class DocstringSectionRaises(DocstringSection): """This class represents a raises section.""" @@ -404,6 +464,22 @@ def __init__(self, value: list[DocstringClass], title: str | None = None) -> Non self.value: list[DocstringClass] = value +class DocstringSectionTypeAliases(DocstringSection): + """This class represents a type aliases section.""" + + kind: DocstringSectionKind = DocstringSectionKind.type_aliases + + def __init__(self, value: list[DocstringTypeAlias], title: str | None = None) -> None: + """Initialize the section. + + Parameters: + value: The section classes. + title: An optional title. + """ + super().__init__(title) + self.value: list[DocstringTypeAlias] = value + + class DocstringSectionModules(DocstringSection): """This class represents a modules section.""" diff --git a/src/_griffe/docstrings/numpy.py b/src/_griffe/docstrings/numpy.py index d20ad3007..d0e294d6b 100644 --- a/src/_griffe/docstrings/numpy.py +++ b/src/_griffe/docstrings/numpy.py @@ -47,8 +47,12 @@ DocstringSectionReceives, DocstringSectionReturns, DocstringSectionText, + DocstringSectionTypeAliases, + DocstringSectionTypeParameters, DocstringSectionWarns, DocstringSectionYields, + DocstringTypeAlias, + DocstringTypeParameter, DocstringWarn, DocstringYield, ) @@ -68,6 +72,7 @@ "deprecated": DocstringSectionKind.deprecated, "parameters": DocstringSectionKind.parameters, "other parameters": DocstringSectionKind.other_parameters, + "type parameters": DocstringSectionKind.type_parameters, "returns": DocstringSectionKind.returns, "yields": DocstringSectionKind.yields, "receives": DocstringSectionKind.receives, @@ -78,6 +83,7 @@ "functions": DocstringSectionKind.functions, "methods": DocstringSectionKind.functions, "classes": DocstringSectionKind.classes, + "type aliases": DocstringSectionKind.type_aliases, "modules": DocstringSectionKind.modules, } @@ -313,6 +319,76 @@ def _read_other_parameters_section( return None, new_offset +def _read_type_parameters_section( + docstring: Docstring, + *, + offset: int, + warn_unknown_params: bool = True, + **options: Any, +) -> tuple[DocstringSectionTypeParameters | None, int]: + type_parameters: list[DocstringTypeParameter] = [] + bound: str | Expr | None + + items, new_offset = _read_block_items(docstring, offset=offset, **options) + + for item in items: + match = _RE_PARAMETER.match(item[0]) + if not match: + docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'") + continue + + names = match.group("names").split(", ") + bound = match.group("type") or None + choices = match.group("choices") + default = None + if choices: + bound = choices + default = choices.split(", ", 1)[0] + elif bound: + match = re.match(r"^(?P.+),\s+default(?: |: |=)(?P.+)$", bound) + if match: + default = match.group("default") + bound = match.group("annotation") + description = "\n".join(item[1:]).rstrip() if len(item) > 1 else "" + + if bound is None: + # try to use the bound from the signature + for name in names: + with suppress(AttributeError, KeyError): + bound = docstring.parent.type_parameters[name].annotation # type: ignore[union-attr] + break + else: + bound = parse_docstring_annotation(bound, docstring, log_level=LogLevel.debug) + + if default is None: + for name in names: + with suppress(AttributeError, KeyError): + default = docstring.parent.type_parameters[name].default # type: ignore[union-attr] + break + + if warn_unknown_params: + with suppress(AttributeError): # for parameters sections in objects without parameters + type_params = docstring.parent.type_parameters # type: ignore[union-attr] + for name in names: + if name not in type_params: + message = f"Type parameter '{name}' does not appear in the {docstring.parent.kind} signature" # type: ignore[union-attr] + for starred_name in (f"*{name}", f"**{name}"): + if starred_name in type_params: + message += f". Did you mean '{starred_name}'?" + break + docstring_warning(docstring, new_offset, message) + + type_parameters.extend( + DocstringTypeParameter(name, value=default, annotation=bound, description=description) for name in names + ) + + if type_parameters: + return DocstringSectionTypeParameters(type_parameters), new_offset + + docstring_warning(docstring, new_offset, f"Empty type parameters section at line {offset}") + return None, new_offset + + def _read_deprecated_section( docstring: Docstring, *, @@ -628,6 +704,26 @@ def _read_classes_section( return DocstringSectionClasses(classes), new_offset +def _read_type_aliases_section( + docstring: Docstring, + *, + offset: int, + **options: Any, +) -> tuple[DocstringSectionTypeAliases | None, int]: + items, new_offset = _read_block_items(docstring, offset=offset, **options) + + if not items: + docstring_warning(docstring, new_offset, f"Empty type aliases section at line {offset}") + return None, new_offset + + type_aliases = [] + for item in items: + name = item[0] + text = dedent("\n".join(item[1:])).strip() + type_aliases.append(DocstringTypeAlias(name=name, description=text)) + return DocstringSectionTypeAliases(type_aliases), new_offset + + def _read_modules_section( docstring: Docstring, *, @@ -743,6 +839,7 @@ def _append_section(sections: list, current: list[str], admonition_title: str) - _section_reader = { DocstringSectionKind.parameters: _read_parameters_section, DocstringSectionKind.other_parameters: _read_other_parameters_section, + DocstringSectionKind.type_parameters: _read_type_parameters_section, DocstringSectionKind.deprecated: _read_deprecated_section, DocstringSectionKind.raises: _read_raises_section, DocstringSectionKind.warns: _read_warns_section, @@ -750,6 +847,7 @@ def _append_section(sections: list, current: list[str], admonition_title: str) - DocstringSectionKind.attributes: _read_attributes_section, DocstringSectionKind.functions: _read_functions_section, DocstringSectionKind.classes: _read_classes_section, + DocstringSectionKind.type_aliases: _read_type_aliases_section, DocstringSectionKind.modules: _read_modules_section, DocstringSectionKind.returns: _read_returns_section, DocstringSectionKind.yields: _read_yields_section, diff --git a/src/griffe/__init__.py b/src/griffe/__init__.py index 381bb6675..ea5f93087 100644 --- a/src/griffe/__init__.py +++ b/src/griffe/__init__.py @@ -231,8 +231,12 @@ DocstringSectionReceives, DocstringSectionReturns, DocstringSectionText, + DocstringSectionTypeAliases, + DocstringSectionTypeParameters, DocstringSectionWarns, DocstringSectionYields, + DocstringTypeAlias, + DocstringTypeParameter, DocstringWarn, DocstringYield, ) @@ -415,9 +419,13 @@ "DocstringSectionReceives", "DocstringSectionReturns", "DocstringSectionText", + "DocstringSectionTypeAliases", + "DocstringSectionTypeParameters", "DocstringSectionWarns", "DocstringSectionYields", "DocstringStyle", + "DocstringTypeAlias", + "DocstringTypeParameter", "DocstringWarn", "DocstringYield", "ExplanationStyle", diff --git a/tests/test_docstrings/test_google.py b/tests/test_docstrings/test_google.py index 15037b224..0a56a08dc 100644 --- a/tests/test_docstrings/test_google.py +++ b/tests/test_docstrings/test_google.py @@ -7,6 +7,8 @@ import pytest +from _griffe.enumerations import TypeParameterKind +from _griffe.models import TypeParameter, TypeParameters from griffe import ( Attribute, Class, @@ -195,10 +197,12 @@ def test_empty_indented_lines_in_section_with_items(parse_google: ParserType) -> "Attributes", "Other Parameters", "Parameters", + "Type Parameters", "Raises", "Receives", "Returns", "Warns", + "Type aliases", "Yields", ], ) @@ -392,6 +396,30 @@ def test_parse_classes_section(parse_google: ParserType) -> None: assert not warnings +def test_parse_type_aliases_section(parse_google: ParserType) -> None: + """Parse Type Aliases sections. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + Type Aliases: + TC: Hello. + TD: Hi. + """ + + sections, warnings = parse_google(docstring) + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.type_aliases + type_alias_c = sections[0].value[0] + assert type_alias_c.name == "TC" + assert type_alias_c.description == "Hello." + type_alias_d = sections[0].value[1] + assert type_alias_d.name == "TD" + assert type_alias_d.description == "Hi." + assert not warnings + + def test_parse_modules_section(parse_google: ParserType) -> None: """Parse Modules sections. @@ -899,6 +927,132 @@ def test_class_uses_init_parameters(parse_google: ParserType) -> None: # assert not warnings +# ============================================================================================= +# Type parameters sections +def test_parse_type_var_tuples_and_param_specs(parse_google: ParserType) -> None: + """Parse type variable tuples and parameter specifications. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + Type Parameters: + T: A type parameter. + C (str, (int, float)): A constrained type parameter. + D complex: A bounded type parameter. + """ + + sections, warnings = parse_google(docstring) + assert len(sections) == 1 + expected_type_parameters = { + "T": ("A type parameter.", None), + "C": ("A constrained type parameter.", "str, (int, float)"), + "D": ("A bounded type parameter.", "complex"), + } + for type_parameter in sections[0].value: + assert type_parameter.name in expected_type_parameters + assert expected_type_parameters[type_parameter.name][0] == type_parameter.description + assert expected_type_parameters[type_parameter.name][1] == type_parameter.annotation + assert not warnings + + +def test_prefer_docstring_bounds_over_annotations(parse_google: ParserType) -> None: + """Prefer the docstring bound over the annotation. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + Type Parameters: + X (str): X type. + Y str, int: Y type. + """ + + sections, warnings = parse_google( + docstring, + parent=Function( + "func", + type_parameters=TypeParameters( + TypeParameter("X", kind=TypeParameterKind.type_var, constraints=["complex"]), + TypeParameter("Y", kind=TypeParameterKind.type_var, bound="int"), + ), + ), + ) + assert len(sections) == 1 + assert not warnings + + assert sections[0].kind is DocstringSectionKind.type_parameters + + (x, y) = sections[0].value + + assert x.name == "X" + assert str(x.bound) == "str" + assert x.constraints is None + + assert y.name == "Y" + assert y.bound is None + assert [str(constraint) for constraint in y.constraints] == ["str", "int"] + + +def test_type_parameter_line_without_colon(parse_google: ParserType) -> None: + """Warn when missing colon. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + Type Parameters: + X is an integer type. + """ + + sections, warnings = parse_google(docstring) + assert len(sections) == 0 # empty sections are discarded + assert len(warnings) == 1 + assert "pair" in warnings[0] + + +def test_warn_about_unknown_type_parameters(parse_google: ParserType) -> None: + """Warn about unknown type parameters in "Type Parameters" sections. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + Type Parameters: + X (int): Integer. + Y (int): Integer. + """ + + _, warnings = parse_google( + docstring, + parent=Function( + "func", + type_parameters=TypeParameters( + TypeParameter("A", kind=TypeParameterKind.type_var), + TypeParameter("Y", kind=TypeParameterKind.type_var), + ), + ), + ) + assert len(warnings) == 1 + assert "'X' does not appear in the function signature" in warnings[0] + + +def test_unknown_type_params_scan_doesnt_crash_without_type_parameters(parse_google: ParserType) -> None: + """Assert we don't crash when parsing type parameters sections and parent object does not have type parameters. + + Parameters: + parse_google: Fixture parser. + """ + docstring = """ + TypeParameters: + This (str): This. + That: That. + """ + + _, warnings = parse_google(docstring, parent=Module("mod")) + assert not warnings + + # ============================================================================================= # Attributes sections def test_retrieve_attributes_annotation_from_parent(parse_google: ParserType) -> None: From 1ad204db1c330741b39efd46df9446c90dcd8d2b Mon Sep 17 00:00:00 2001 From: Victor Westerhuis Date: Sun, 5 Jan 2025 19:11:15 +0100 Subject: [PATCH 3/8] Resolve names using annotation scopes --- src/_griffe/agents/inspector.py | 85 ++++++++++++++++++++++----------- src/_griffe/agents/visitor.py | 76 ++++++++++++++++++----------- src/_griffe/docstrings/utils.py | 6 +++ src/_griffe/expressions.py | 56 ++++++++++++++++------ src/_griffe/models.py | 66 +++++++++++++++++++++++-- tests/test_expressions.py | 24 ++++++++++ tests/test_models.py | 26 ++++++++++ 7 files changed, 264 insertions(+), 75 deletions(-) diff --git a/src/_griffe/agents/inspector.py b/src/_griffe/agents/inspector.py index 39d8ef730..fa7901266 100644 --- a/src/_griffe/agents/inspector.py +++ b/src/_griffe/agents/inspector.py @@ -338,10 +338,10 @@ def inspect_class(self, node: ObjectNode) -> None: name=node.name, docstring=self._get_docstring(node), bases=bases, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), lineno=lineno, endlineno=endlineno, ) + class_.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, class_)) self.current.set_member(node.name, class_) self.current = class_ self.extensions.call("on_instance", node=node, obj=class_, agent=self) @@ -446,18 +446,10 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: except Exception: # noqa: BLE001 # so many exceptions can be raised here: # AttributeError, NameError, RuntimeError, ValueError, TokenError, TypeError - parameters = None - returns = None + signature = None + return_annotation = None else: - parameters = Parameters( - *[_convert_parameter(parameter, parent=self.current) for parameter in signature.parameters.values()], - ) - return_annotation = signature.return_annotation - returns = ( - None - if return_annotation is _empty - else _convert_object_to_annotation(return_annotation, parent=self.current) - ) + return_annotation = signature.return_annotation # type: ignore[union-attr] lineno, endlineno = self._get_linenos(node) @@ -467,21 +459,41 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: obj = Attribute( name=node.name, value=None, - annotation=returns, docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, ) + if return_annotation is not None: + obj.annotation = ( + None + if return_annotation is _empty + else _convert_object_to_annotation( + return_annotation, + parent=self.current, + annotation_scope=self.current if self.current.is_class else None, # type: ignore[arg-type] + ) + ) else: obj = Function( name=node.name, - parameters=parameters, - returns=returns, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, ) + obj.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, obj)) + if signature is not None: + obj.parameters = Parameters( + *[ + _convert_parameter(parameter, parent=self.current, annotation_scope=obj) + for parameter in signature.parameters.values() + ], + ) + obj.returns = ( + None + if return_annotation is _empty + else _convert_object_to_annotation(return_annotation, parent=self.current, annotation_scope=obj) + ) + obj.labels |= labels self.current.set_member(node.name, obj) self.extensions.call("on_instance", node=node, obj=obj, agent=self) @@ -503,13 +515,13 @@ def inspect_type_alias(self, node: ObjectNode) -> None: type_alias = TypeAlias( name=node.name, - value=_convert_type_to_annotation(node.obj.__value__, self.current), lineno=lineno, endlineno=endlineno, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), docstring=self._get_docstring(node), parent=self.current, ) + type_alias.value = _convert_type_to_annotation(node.obj.__value__, self.current, type_alias) + type_alias.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, type_alias)) self.current.set_member(node.name, type_alias) self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) @@ -579,10 +591,16 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Expr | None = Non } -def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> Parameter: +def _convert_parameter( + parameter: SignatureParameter, + parent: Module | Class, + annotation_scope: Function | Class | TypeAlias, +) -> Parameter: name = parameter.name annotation = ( - None if parameter.annotation is _empty else _convert_object_to_annotation(parameter.annotation, parent=parent) + None + if parameter.annotation is _empty + else _convert_object_to_annotation(parameter.annotation, parent=parent, annotation_scope=annotation_scope) ) kind = _parameter_kind_map[parameter.kind] if parameter.default is _empty: @@ -595,7 +613,11 @@ def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> return Parameter(name, annotation=annotation, kind=kind, default=default) -def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: +def _convert_object_to_annotation( + obj: Any, + parent: Module | Class, + annotation_scope: Function | Class | TypeAlias | None, +) -> str | Expr | None: # even when *we* import future annotations, # the object from which we get a signature # can come from modules which did *not* import them, @@ -612,7 +634,7 @@ def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Exp annotation_node = compile(obj, mode="eval", filename="<>", flags=ast.PyCF_ONLY_AST, optimize=2) except SyntaxError: return obj - return safe_get_annotation(annotation_node.body, parent=parent) # type: ignore[attr-defined] + return safe_get_annotation(annotation_node.body, parent=parent, annotation_scope=annotation_scope) # type: ignore[attr-defined] _type_parameter_kind_map = { @@ -630,6 +652,7 @@ def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Exp def _convert_type_parameters( obj: Any, parent: Module | Class, + annotation_scope: Function | Class | TypeAlias, ) -> list[TypeParameter]: obj = unwrap(obj) @@ -640,9 +663,9 @@ def _convert_type_parameters( for type_parameter in obj.__type_params__: bound = getattr(type_parameter, "__bound__", None) if bound is not None: - bound = _convert_type_to_annotation(bound, parent=parent) + bound = _convert_type_to_annotation(bound, parent=parent, annotation_scope=annotation_scope) constraints: list[str | Expr] = [ - _convert_type_to_annotation(constraint, parent=parent) # type: ignore[misc] + _convert_type_to_annotation(constraint, parent=parent, annotation_scope=annotation_scope) # type: ignore[misc] for constraint in getattr(type_parameter, "__constraints__", ()) ] @@ -650,6 +673,7 @@ def _convert_type_parameters( default = _convert_type_to_annotation( type_parameter.__default__, parent=parent, + annotation_scope=annotation_scope, ) else: default = None @@ -667,14 +691,19 @@ def _convert_type_parameters( return type_parameters -def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: +def _convert_type_to_annotation( + obj: Any, + parent: Module | Class, + annotation_scope: Function | Class | TypeAlias, +) -> str | Expr | None: origin = typing.get_origin(obj) if origin is None: - return _convert_object_to_annotation(obj, parent=parent) + return _convert_object_to_annotation(obj, parent=parent, annotation_scope=annotation_scope) args: Sequence[str | Expr | None] = [ - _convert_type_to_annotation(arg, parent=parent) for arg in typing.get_args(obj) + _convert_type_to_annotation(arg, parent=parent, annotation_scope=annotation_scope) + for arg in typing.get_args(obj) ] # YORE: EOL 3.9: Replace block with lines 2-3. @@ -682,7 +711,7 @@ def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr if origin is types.UnionType: return functools.reduce(lambda left, right: ExprBinOp(left, "|", right), args) # type: ignore[arg-type] - origin = _convert_type_to_annotation(origin, parent=parent) + origin = _convert_type_to_annotation(origin, parent=parent, annotation_scope=annotation_scope) if origin is None: return None diff --git a/src/_griffe/agents/visitor.py b/src/_griffe/agents/visitor.py index 26d6c5226..e428d44ff 100644 --- a/src/_griffe/agents/visitor.py +++ b/src/_griffe/agents/visitor.py @@ -215,13 +215,22 @@ def _get_docstring(self, node: ast.AST, *, strict: bool = False) -> Docstring | def _get_type_parameters( self, statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias, + annotation_scope: Function | Class | TypeAlias, ) -> list[TypeParameter]: return [ TypeParameter( type_param.name, # type: ignore[attr-defined] kind=self._type_parameter_kind_map[type(type_param)], - bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current), - default=safe_get_annotation(getattr(type_param, "default_value", None), parent=self.current), + bound=safe_get_annotation( + getattr(type_param, "bound", None), + parent=self.current, + annotation_scope=annotation_scope, + ), + default=safe_get_annotation( + getattr(type_param, "default_value", None), + parent=self.current, + annotation_scope=annotation_scope, + ), ) for type_param in statement.type_params ] @@ -230,6 +239,7 @@ def _get_type_parameters( def _get_type_parameters( self, _statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, + _obj: Function | Class | TypeAlias, ) -> list[TypeParameter]: return [] @@ -316,20 +326,20 @@ def visit_classdef(self, node: ast.ClassDef) -> None: else: lineno = node.lineno - # handle base classes - bases = [safe_get_base_class(base, parent=self.current) for base in node.bases] - class_ = Class( name=node.name, lineno=lineno, endlineno=node.end_lineno, docstring=self._get_docstring(node), decorators=decorators, - type_parameters=TypeParameters(*self._get_type_parameters(node)), - bases=bases, # type: ignore[arg-type] runtime=not self.type_guarded, ) + + # handle base classes + class_.bases = [safe_get_base_class(base, parent=self.current, annotation_scope=class_) for base in node.bases] # type: ignore[misc] + class_.type_parameters = TypeParameters(*self._get_type_parameters(node, class_)) 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_, agent=self) @@ -418,25 +428,39 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: attribute = Attribute( name=node.name, value=None, - annotation=safe_get_annotation(node.returns, parent=self.current), lineno=node.lineno, endlineno=node.end_lineno, docstring=self._get_docstring(node), runtime=not self.type_guarded, ) + attribute.annotation = safe_get_annotation( + node.returns, + parent=self.current, + annotation_scope=self.current if not self.current.is_module else None, # type: ignore[arg-type] + ) attribute.labels |= labels self.current.set_member(node.name, attribute) self.extensions.call("on_instance", node=node, obj=attribute, agent=self) self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self) return + function = Function( + name=node.name, + lineno=lineno, + endlineno=node.end_lineno, + decorators=decorators, + docstring=self._get_docstring(node), + runtime=not self.type_guarded, + parent=self.current, + ) + # handle parameters - parameters = Parameters( + function.parameters = Parameters( *[ Parameter( name, kind=kind, - annotation=safe_get_annotation(annotation, parent=self.current), + annotation=safe_get_annotation(annotation, parent=self.current, annotation_scope=function), default=default if isinstance(default, str) else safe_get_expression(default, parent=self.current, parse_strings=False), @@ -444,19 +468,8 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: for name, annotation, kind, default in get_parameters(node.args) ], ) - - function = Function( - name=node.name, - lineno=lineno, - endlineno=node.end_lineno, - parameters=parameters, - returns=safe_get_annotation(node.returns, parent=self.current), - decorators=decorators, - type_parameters=TypeParameters(*self._get_type_parameters(node)), - docstring=self._get_docstring(node), - runtime=not self.type_guarded, - parent=self.current, - ) + function.returns = safe_get_annotation(node.returns, parent=self.current, annotation_scope=function) + function.type_parameters = TypeParameters(*self._get_type_parameters(node, function)) property_function = self.get_base_property(decorators, function) @@ -519,8 +532,6 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: name = node.name.id - value = safe_get_expression(node.value, parent=self.current) - try: docstring = self._get_docstring(ast_next(node), strict=True) except (LastNodeError, AttributeError): @@ -528,13 +539,15 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: type_alias = TypeAlias( name=name, - value=value, lineno=node.lineno, endlineno=node.end_lineno, - type_parameters=TypeParameters(*self._get_type_parameters(node)), docstring=docstring, parent=self.current, ) + + type_alias.value = safe_get_annotation(node.value, parent=self.current, annotation_scope=type_alias) + type_alias.type_parameters = TypeParameters(*self._get_type_parameters(node, type_alias)) + self.current.set_member(name, type_alias) self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) @@ -711,7 +724,14 @@ def visit_annassign(self, node: ast.AnnAssign) -> None: Parameters: node: The node to visit. """ - self.handle_attribute(node, safe_get_annotation(node.annotation, parent=self.current)) + self.handle_attribute( + node, + safe_get_annotation( + node.annotation, + parent=self.current, + annotation_scope=self.current if not self.current.is_module else None, # type: ignore[arg-type] + ), + ) def visit_augassign(self, node: ast.AugAssign) -> None: """Visit an augmented assignment node. diff --git a/src/_griffe/docstrings/utils.py b/src/_griffe/docstrings/utils.py index 8070a319b..9c9e663cf 100644 --- a/src/_griffe/docstrings/utils.py +++ b/src/_griffe/docstrings/utils.py @@ -70,9 +70,15 @@ def parse_docstring_annotation( ): code = compile(annotation, mode="eval", filename="", flags=PyCF_ONLY_AST, optimize=2) if code.body: # type: ignore[attr-defined] + annotation_scope = docstring.parent + if annotation_scope is not None and annotation_scope.is_attribute: + annotation_scope = annotation_scope.parent + if annotation_scope is not None and annotation_scope.is_module: + annotation_scope = None name_or_expr = safe_get_annotation( code.body, # type: ignore[attr-defined] parent=docstring.parent, # type: ignore[arg-type] + annotation_scope=annotation_scope, # type: ignore[arg-type] log_level=log_level, ) return name_or_expr or annotation diff --git a/src/_griffe/expressions.py b/src/_griffe/expressions.py index 9ad75d72a..94c391c91 100644 --- a/src/_griffe/expressions.py +++ b/src/_griffe/expressions.py @@ -23,7 +23,7 @@ from collections.abc import Iterable, Iterator, Sequence from pathlib import Path - from _griffe.models import Class, Module + from _griffe.models import Class, Function, Module, TypeAlias def _yield(element: str | Expr | tuple[str | Expr, ...], *, flat: bool = True) -> Iterator[str | Expr]: @@ -59,7 +59,7 @@ def _field_as_dict( **kwargs: Any, ) -> str | bool | None | list | dict: if isinstance(element, Expr): - return _expr_as_dict(element, **kwargs) + return element.as_dict(**kwargs) if isinstance(element, list): return [_field_as_dict(elem, **kwargs) for elem in element] return element @@ -596,9 +596,11 @@ class ExprName(Expr): """Actual name.""" parent: str | ExprName | Module | Class | None = None """Parent (for resolution in its scope).""" + annotation_scope: str | Function | TypeAlias | Class | None = None + """Annotation scope (for resolution of names in annotations).""" def __eq__(self, other: object) -> bool: - """Two name expressions are equal if they have the same `name` value (`parent` is ignored).""" + """Two name expressions are equal if they have the same `name` value (`parent` and `annotation_scope` are ignored).""" if isinstance(other, ExprName): return self.name == other.name return NotImplemented @@ -621,16 +623,24 @@ def path(self) -> str: @property def canonical_path(self) -> str: """The canonical name (resolved one, not alias name).""" - if self.parent is None: - return self.name if isinstance(self.parent, ExprName): return f"{self.parent.canonical_path}.{self.name}" if isinstance(self.parent, str): return f"{self.parent}.{self.name}" - try: - return self.parent.resolve(self.name) - except NameResolutionError: - return self.name + if self.annotation_scope is not None: + try: + if not isinstance(self.annotation_scope, str): + return self.annotation_scope.resolve_annotation(self.name) + if self.parent is not None: + return self.parent.modules_collection[self.annotation_scope].resolve_annotation(self.name) + except NameResolutionError: + pass + elif self.parent is not None: + try: + return self.parent.resolve(self.name) + except NameResolutionError: + pass + return self.name @property def resolved(self) -> Module | Class | None: @@ -668,6 +678,17 @@ def is_enum_value(self) -> bool: except Exception: # noqa: BLE001 return False + @property + def is_type_parameter(self) -> bool: + """Whether this name resolves to a type parameter.""" + return ":" in self.canonical_path and "." not in self.canonical_path.partition(":")[2] + + def as_dict(self, **kwargs: Any) -> dict[str, Any]: + base = super(type(self), self).as_dict(**kwargs) + if self.annotation_scope is not None and not isinstance(self.annotation_scope, str): + base["annotation_scope"] = self.annotation_scope.path + return base + # YORE: EOL 3.9: Replace `**_dataclass_opts` with `slots=True` within line. @dataclass(eq=True, **_dataclass_opts) @@ -1064,8 +1085,13 @@ def _build_listcomp(node: ast.ListComp, parent: Module | Class, **kwargs: Any) - return ExprListComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators]) -def _build_name(node: ast.Name, parent: Module | Class, **kwargs: Any) -> Expr: # noqa: ARG001 - return ExprName(node.id, parent) +def _build_name( + node: ast.Name, + parent: Module | Class, + annotation_scope: Function | Class | TypeAlias | None = None, + **kwargs: Any, # noqa: ARG001 +) -> Expr: + return ExprName(node.id, parent, annotation_scope=annotation_scope) def _build_named_expr(node: ast.NamedExpr, parent: Module | Class, **kwargs: Any) -> Expr: @@ -1184,6 +1210,7 @@ def get_expression( parent: Module | Class, *, parse_strings: bool | None = None, + annotation_scope: Function | Class | TypeAlias | None = None, ) -> Expr | None: """Build an expression from an AST. @@ -1204,7 +1231,7 @@ def get_expression( parse_strings = False else: parse_strings = not module.imports_future_annotations - return _build(node, parent, parse_strings=parse_strings) + return _build(node, parent, parse_strings=parse_strings, annotation_scope=annotation_scope) def safe_get_expression( @@ -1212,6 +1239,7 @@ def safe_get_expression( parent: Module | Class, *, parse_strings: bool | None = None, + annotation_scope: Function | Class | TypeAlias | None = None, log_level: LogLevel | None = LogLevel.error, msg_format: str = "{path}:{lineno}: Failed to get expression from {node_class}: {error}", ) -> Expr | None: @@ -1229,7 +1257,7 @@ def safe_get_expression( A string or resovable name or expression. """ try: - return get_expression(node, parent, parse_strings=parse_strings) + return get_expression(node, parent, parse_strings=parse_strings, annotation_scope=annotation_scope) except Exception as error: # noqa: BLE001 if log_level is None: return None @@ -1255,7 +1283,7 @@ def safe_get_expression( get_base_class = partial(get_expression, parse_strings=False) safe_get_base_class = partial( safe_get_expression, - parse_strings=False, + parse_strings=None, msg_format=_msg_format % "base class", ) get_condition = partial(get_expression, parse_strings=False) diff --git a/src/_griffe/models.py b/src/_griffe/models.py index ec7f4ec50..529d607fa 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -231,7 +231,7 @@ def __init__( """The parameter default value.""" self.docstring: Docstring | None = docstring """The parameter docstring.""" - # The parent function is set in `Function.__init__`, + # The parent function is set in `Function.parameters`, # when the parameters are assigned to the function. self.function: Function | None = None """The parent function of the parameter.""" @@ -1165,6 +1165,40 @@ def resolve(self, name: str) -> str: # Recurse in parent. return self.parent.resolve(name) + def resolve_annotation(self, name: str) -> str: + """Resolve a name within this object's annotation scope. + + Parameters: + name: The name to resolve. + + Raises: + NameResolutionError: When the name could not be resolved. + + Returns: + The resolved name, pointing to either an object or a type parameter. + """ + if name in self.type_parameters: + type_parameter = self.type_parameters[name] + if type_parameter.kind == TypeParameterKind.type_var: + prefix = "" + elif type_parameter.kind == TypeParameterKind.type_var_tuple: + prefix = "*" + elif type_parameter.kind == TypeParameterKind.param_spec: + prefix = "**" + return f"{self.path}:{prefix}{name}" + + if self.parent: + if name in self.parent.members: + if self.parent.members[name].is_alias: + return self.parent.members[name].target_path # type: ignore[union-attr] + return self.parent.members[name].path + if self.parent.is_class and name == self.parent.name: + return self.parent.canonical_path + if not self.parent.is_module: + return self.parent.resolve_annotation(name) + + raise NameResolutionError(f"{name} could not be resolved in the scope of {self.path}") + def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: """Return this object's data as a dictionary. @@ -1734,6 +1768,20 @@ def resolve(self, name: str) -> str: """ return self.final_target.resolve(name) + def resolve_annotation(self, name: str) -> str: + """Resolve a name within this object's annotation scope. + + Parameters: + name: The name to resolve. + + Raises: + NameResolutionError: When the name could not be resolved. + + Returns: + The resolved name, pointing to either an object or a type parameter. + """ + return self.final_target.resolve_annotation(name) + # SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE/TYPE ALIAS PROXIES --------------- # These methods and properties exist on targets of specific kind. # We first try to reach the final target, triggering alias resolution errors @@ -2275,8 +2323,7 @@ def __init__( **kwargs: See [`griffe.Object`][]. """ super().__init__(*args, **kwargs) - self.parameters: Parameters = parameters or Parameters() - """The function parameters.""" + self.parameters = parameters or Parameters() self.returns: str | Expr | None = returns """The function return type annotation.""" self.decorators: list[Decorator] = decorators or [] @@ -2284,7 +2331,16 @@ def __init__( self.overloads: list[Function] | None = None """The overloaded signatures of this function.""" - for parameter in self.parameters: + @property + def parameters(self) -> Parameters: + """The function parameters.""" + return self._parameters + + @parameters.setter + def parameters(self, parameters: Parameters) -> None: + self._parameters = parameters + + for parameter in self._parameters: parameter.function = self @property @@ -2367,7 +2423,7 @@ class TypeAlias(Object): def __init__( self, *args: Any, - value: str | Expr | None, + value: str | Expr | None = None, **kwargs: Any, ) -> None: """Initialize the function. diff --git a/tests/test_expressions.py b/tests/test_expressions.py index 211e63d6a..530e696b1 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import sys import pytest @@ -75,6 +76,29 @@ def test_resolving_full_names() -> None: assert module["attribute2"].annotation.canonical_path == "package.module.Class" +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_resolving_type_parameters() -> None: + """Assert type parameters correctly transformed to their fully-resolved form.""" + with temporary_visited_module( + """ + class C[T]: + class D[T]: + def func[Y](self, arg1: T, arg2: Y): pass + attr: T + + def func[Z](arg1: T, arg2: Y): pass + """, + ) as module: + assert module["C.D.func"].parameters["arg1"].annotation.canonical_path == "module.C.D:T" + assert module["C.D.func"].parameters["arg2"].annotation.canonical_path == "module.C.D.func:Y" + + assert module["C.D.attr"].annotation.canonical_path == "module.C.D:T" + + assert module["C.func"].parameters["arg1"].annotation.canonical_path == "module.C:T" + assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y" + + @pytest.mark.parametrize("code", syntax_examples) def test_expressions(code: str) -> None: """Test building annotations from AST nodes. diff --git a/tests/test_models.py b/tests/test_models.py index a1b68fcb4..63baa31b5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from copy import deepcopy from textwrap import dedent @@ -499,6 +500,31 @@ def method(self): assert module["Class.method"].resolve("instance_attribute") == "module.Class.instance_attribute" +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_annotation_resolution() -> None: + """Names are correctly resolved in the annotation scope of an object.""" + with temporary_visited_module( + """ + class C[T]: + class D[T]: + def func[Y](self, arg1: T, arg2: Y): pass + + def func[Z](arg1: T, arg2: Y): pass + """, + ) as module: + assert module["C.D"].resolve_annotation("T") == "module.C.D:T" + + assert module["C.D.func"].resolve_annotation("T") == "module.C.D:T" + assert module["C.D.func"].resolve_annotation("Y") == "module.C.D.func:Y" + + assert module["C"].resolve_annotation("T") == "module.C:T" + + assert module["C.func"].resolve_annotation("T") == "module.C:T" + with pytest.raises(NameResolutionError): + module["C.func"].resolve_annotation("Y") + + def test_set_parameters() -> None: """We can set parameters.""" parameters = Parameters() From 349927a537bd5e15310188904af66066b90e9a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Sat, 15 Mar 2025 14:02:17 +0100 Subject: [PATCH 4/8] revert: Resolve names using annotation scopes This reverts commit 1ad204db1c330741b39efd46df9446c90dcd8d2b. --- src/_griffe/agents/inspector.py | 85 +++++++++++---------------------- src/_griffe/agents/visitor.py | 76 +++++++++++------------------ src/_griffe/docstrings/utils.py | 6 --- src/_griffe/expressions.py | 56 ++++++---------------- src/_griffe/models.py | 66 ++----------------------- tests/test_expressions.py | 24 ---------- tests/test_models.py | 26 ---------- 7 files changed, 75 insertions(+), 264 deletions(-) diff --git a/src/_griffe/agents/inspector.py b/src/_griffe/agents/inspector.py index 68c72e5d5..912f944d9 100644 --- a/src/_griffe/agents/inspector.py +++ b/src/_griffe/agents/inspector.py @@ -339,10 +339,10 @@ def inspect_class(self, node: ObjectNode) -> None: name=node.name, docstring=self._get_docstring(node), bases=bases, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), lineno=lineno, endlineno=endlineno, ) - class_.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, class_)) self.current.set_member(node.name, class_) self.current = class_ self.extensions.call("on_instance", node=node, obj=class_, agent=self) @@ -455,10 +455,18 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: except Exception: # noqa: BLE001 # so many exceptions can be raised here: # AttributeError, NameError, RuntimeError, ValueError, TokenError, TypeError - signature = None - return_annotation = None + parameters = None + returns = None else: - return_annotation = signature.return_annotation # type: ignore[union-attr] + parameters = Parameters( + *[_convert_parameter(parameter, parent=self.current) for parameter in signature.parameters.values()], + ) + return_annotation = signature.return_annotation + returns = ( + None + if return_annotation is _empty + else _convert_object_to_annotation(return_annotation, parent=self.current) + ) lineno, endlineno = self._get_linenos(node) @@ -468,41 +476,21 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: obj = Attribute( name=node.name, value=None, + annotation=returns, docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, ) - if return_annotation is not None: - obj.annotation = ( - None - if return_annotation is _empty - else _convert_object_to_annotation( - return_annotation, - parent=self.current, - annotation_scope=self.current if self.current.is_class else None, # type: ignore[arg-type] - ) - ) else: obj = Function( name=node.name, + parameters=parameters, + returns=returns, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, ) - obj.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, obj)) - if signature is not None: - obj.parameters = Parameters( - *[ - _convert_parameter(parameter, parent=self.current, annotation_scope=obj) - for parameter in signature.parameters.values() - ], - ) - obj.returns = ( - None - if return_annotation is _empty - else _convert_object_to_annotation(return_annotation, parent=self.current, annotation_scope=obj) - ) - obj.labels |= labels self.current.set_member(node.name, obj) self.extensions.call("on_instance", node=node, obj=obj, agent=self) @@ -524,13 +512,13 @@ def inspect_type_alias(self, node: ObjectNode) -> None: type_alias = TypeAlias( name=node.name, + value=_convert_type_to_annotation(node.obj.__value__, self.current), lineno=lineno, endlineno=endlineno, + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), docstring=self._get_docstring(node), parent=self.current, ) - type_alias.value = _convert_type_to_annotation(node.obj.__value__, self.current, type_alias) - type_alias.type_parameters = TypeParameters(*_convert_type_parameters(node.obj, self.current, type_alias)) self.current.set_member(node.name, type_alias) self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) @@ -600,16 +588,10 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Expr | None = Non } -def _convert_parameter( - parameter: SignatureParameter, - parent: Module | Class, - annotation_scope: Function | Class | TypeAlias, -) -> Parameter: +def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> Parameter: name = parameter.name annotation = ( - None - if parameter.annotation is _empty - else _convert_object_to_annotation(parameter.annotation, parent=parent, annotation_scope=annotation_scope) + None if parameter.annotation is _empty else _convert_object_to_annotation(parameter.annotation, parent=parent) ) kind = _parameter_kind_map[parameter.kind] if parameter.default is _empty: @@ -622,11 +604,7 @@ def _convert_parameter( return Parameter(name, annotation=annotation, kind=kind, default=default) -def _convert_object_to_annotation( - obj: Any, - parent: Module | Class, - annotation_scope: Function | Class | TypeAlias | None, -) -> str | Expr | None: +def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: # even when *we* import future annotations, # the object from which we get a signature # can come from modules which did *not* import them, @@ -643,7 +621,7 @@ def _convert_object_to_annotation( annotation_node = compile(obj, mode="eval", filename="<>", flags=ast.PyCF_ONLY_AST, optimize=2) except SyntaxError: return obj - return safe_get_annotation(annotation_node.body, parent=parent, annotation_scope=annotation_scope) # type: ignore[attr-defined] + return safe_get_annotation(annotation_node.body, parent=parent) # type: ignore[attr-defined] _type_parameter_kind_map = { @@ -661,7 +639,6 @@ def _convert_object_to_annotation( def _convert_type_parameters( obj: Any, parent: Module | Class, - annotation_scope: Function | Class | TypeAlias, ) -> list[TypeParameter]: obj = unwrap(obj) @@ -672,9 +649,9 @@ def _convert_type_parameters( for type_parameter in obj.__type_params__: bound = getattr(type_parameter, "__bound__", None) if bound is not None: - bound = _convert_type_to_annotation(bound, parent=parent, annotation_scope=annotation_scope) + bound = _convert_type_to_annotation(bound, parent=parent) constraints: list[str | Expr] = [ - _convert_type_to_annotation(constraint, parent=parent, annotation_scope=annotation_scope) # type: ignore[misc] + _convert_type_to_annotation(constraint, parent=parent) # type: ignore[misc] for constraint in getattr(type_parameter, "__constraints__", ()) ] @@ -682,7 +659,6 @@ def _convert_type_parameters( default = _convert_type_to_annotation( type_parameter.__default__, parent=parent, - annotation_scope=annotation_scope, ) else: default = None @@ -700,19 +676,14 @@ def _convert_type_parameters( return type_parameters -def _convert_type_to_annotation( - obj: Any, - parent: Module | Class, - annotation_scope: Function | Class | TypeAlias, -) -> str | Expr | None: +def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: origin = typing.get_origin(obj) if origin is None: - return _convert_object_to_annotation(obj, parent=parent, annotation_scope=annotation_scope) + return _convert_object_to_annotation(obj, parent=parent) args: Sequence[str | Expr | None] = [ - _convert_type_to_annotation(arg, parent=parent, annotation_scope=annotation_scope) - for arg in typing.get_args(obj) + _convert_type_to_annotation(arg, parent=parent) for arg in typing.get_args(obj) ] # YORE: EOL 3.9: Replace block with lines 2-3. @@ -720,7 +691,7 @@ def _convert_type_to_annotation( if origin is types.UnionType: return functools.reduce(lambda left, right: ExprBinOp(left, "|", right), args) # type: ignore[arg-type] - origin = _convert_type_to_annotation(origin, parent=parent, annotation_scope=annotation_scope) + origin = _convert_type_to_annotation(origin, parent=parent) if origin is None: return None diff --git a/src/_griffe/agents/visitor.py b/src/_griffe/agents/visitor.py index 509f9859c..73fe96ba2 100644 --- a/src/_griffe/agents/visitor.py +++ b/src/_griffe/agents/visitor.py @@ -216,22 +216,13 @@ def _get_docstring(self, node: ast.AST, *, strict: bool = False) -> Docstring | def _get_type_parameters( self, statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias, - annotation_scope: Function | Class | TypeAlias, ) -> list[TypeParameter]: return [ TypeParameter( type_param.name, # type: ignore[attr-defined] kind=self._type_parameter_kind_map[type(type_param)], - bound=safe_get_annotation( - getattr(type_param, "bound", None), - parent=self.current, - annotation_scope=annotation_scope, - ), - default=safe_get_annotation( - getattr(type_param, "default_value", None), - parent=self.current, - annotation_scope=annotation_scope, - ), + bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current), + default=safe_get_annotation(getattr(type_param, "default_value", None), parent=self.current), ) for type_param in statement.type_params ] @@ -240,7 +231,6 @@ def _get_type_parameters( def _get_type_parameters( self, _statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, - _obj: Function | Class | TypeAlias, ) -> list[TypeParameter]: return [] @@ -327,20 +317,20 @@ def visit_classdef(self, node: ast.ClassDef) -> None: else: lineno = node.lineno + # handle base classes + bases = [safe_get_base_class(base, parent=self.current) for base in node.bases] + class_ = Class( name=node.name, lineno=lineno, endlineno=node.end_lineno, docstring=self._get_docstring(node), decorators=decorators, + type_parameters=TypeParameters(*self._get_type_parameters(node)), + bases=bases, # type: ignore[arg-type] runtime=not self.type_guarded, ) - - # handle base classes - class_.bases = [safe_get_base_class(base, parent=self.current, annotation_scope=class_) for base in node.bases] # type: ignore[misc] - class_.type_parameters = TypeParameters(*self._get_type_parameters(node, class_)) 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_, agent=self) @@ -429,39 +419,25 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: attribute = Attribute( name=node.name, value=None, + annotation=safe_get_annotation(node.returns, parent=self.current), lineno=node.lineno, endlineno=node.end_lineno, docstring=self._get_docstring(node), runtime=not self.type_guarded, ) - attribute.annotation = safe_get_annotation( - node.returns, - parent=self.current, - annotation_scope=self.current if not self.current.is_module else None, # type: ignore[arg-type] - ) attribute.labels |= labels self.current.set_member(node.name, attribute) self.extensions.call("on_instance", node=node, obj=attribute, agent=self) self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self) return - function = Function( - name=node.name, - lineno=lineno, - endlineno=node.end_lineno, - decorators=decorators, - docstring=self._get_docstring(node), - runtime=not self.type_guarded, - parent=self.current, - ) - # handle parameters - function.parameters = Parameters( + parameters = Parameters( *[ Parameter( name, kind=kind, - annotation=safe_get_annotation(annotation, parent=self.current, annotation_scope=function), + annotation=safe_get_annotation(annotation, parent=self.current), default=default if isinstance(default, str) else safe_get_expression(default, parent=self.current, parse_strings=False), @@ -469,8 +445,19 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: for name, annotation, kind, default in get_parameters(node.args) ], ) - function.returns = safe_get_annotation(node.returns, parent=self.current, annotation_scope=function) - function.type_parameters = TypeParameters(*self._get_type_parameters(node, function)) + + function = Function( + name=node.name, + lineno=lineno, + endlineno=node.end_lineno, + parameters=parameters, + returns=safe_get_annotation(node.returns, parent=self.current), + decorators=decorators, + type_parameters=TypeParameters(*self._get_type_parameters(node)), + docstring=self._get_docstring(node), + runtime=not self.type_guarded, + parent=self.current, + ) property_function = self.get_base_property(decorators, function) @@ -533,6 +520,8 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: name = node.name.id + value = safe_get_expression(node.value, parent=self.current) + try: docstring = self._get_docstring(ast_next(node), strict=True) except (LastNodeError, AttributeError): @@ -540,15 +529,13 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: type_alias = TypeAlias( name=name, + value=value, lineno=node.lineno, endlineno=node.end_lineno, + type_parameters=TypeParameters(*self._get_type_parameters(node)), docstring=docstring, parent=self.current, ) - - type_alias.value = safe_get_annotation(node.value, parent=self.current, annotation_scope=type_alias) - type_alias.type_parameters = TypeParameters(*self._get_type_parameters(node, type_alias)) - self.current.set_member(name, type_alias) self.extensions.call("on_instance", node=node, obj=type_alias, agent=self) self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self) @@ -725,14 +712,7 @@ def visit_annassign(self, node: ast.AnnAssign) -> None: Parameters: node: The node to visit. """ - self.handle_attribute( - node, - safe_get_annotation( - node.annotation, - parent=self.current, - annotation_scope=self.current if not self.current.is_module else None, # type: ignore[arg-type] - ), - ) + self.handle_attribute(node, safe_get_annotation(node.annotation, parent=self.current)) def visit_augassign(self, node: ast.AugAssign) -> None: """Visit an augmented assignment node. diff --git a/src/_griffe/docstrings/utils.py b/src/_griffe/docstrings/utils.py index 849e7975b..1b56d07a4 100644 --- a/src/_griffe/docstrings/utils.py +++ b/src/_griffe/docstrings/utils.py @@ -70,15 +70,9 @@ def parse_docstring_annotation( ): code = compile(annotation, mode="eval", filename="", flags=PyCF_ONLY_AST, optimize=2) if code.body: # type: ignore[attr-defined] - annotation_scope = docstring.parent - if annotation_scope is not None and annotation_scope.is_attribute: - annotation_scope = annotation_scope.parent - if annotation_scope is not None and annotation_scope.is_module: - annotation_scope = None name_or_expr = safe_get_annotation( code.body, # type: ignore[attr-defined] parent=docstring.parent, # type: ignore[arg-type] - annotation_scope=annotation_scope, # type: ignore[arg-type] log_level=log_level, ) return name_or_expr or annotation diff --git a/src/_griffe/expressions.py b/src/_griffe/expressions.py index 65f87df4f..ac4a57b49 100644 --- a/src/_griffe/expressions.py +++ b/src/_griffe/expressions.py @@ -23,7 +23,7 @@ from collections.abc import Iterable, Iterator, Sequence from pathlib import Path - from _griffe.models import Class, Function, Module, TypeAlias + from _griffe.models import Class, Module def _yield(element: str | Expr | tuple[str | Expr, ...], *, flat: bool = True) -> Iterator[str | Expr]: @@ -59,7 +59,7 @@ def _field_as_dict( **kwargs: Any, ) -> str | bool | None | list | dict: if isinstance(element, Expr): - return element.as_dict(**kwargs) + return _expr_as_dict(element, **kwargs) if isinstance(element, list): return [_field_as_dict(elem, **kwargs) for elem in element] return element @@ -601,11 +601,9 @@ class ExprName(Expr): """Actual name.""" parent: str | ExprName | Module | Class | None = None """Parent (for resolution in its scope).""" - annotation_scope: str | Function | TypeAlias | Class | None = None - """Annotation scope (for resolution of names in annotations).""" def __eq__(self, other: object) -> bool: - """Two name expressions are equal if they have the same `name` value (`parent` and `annotation_scope` are ignored).""" + """Two name expressions are equal if they have the same `name` value (`parent` is ignored).""" if isinstance(other, ExprName): return self.name == other.name return NotImplemented @@ -628,24 +626,16 @@ def path(self) -> str: @property def canonical_path(self) -> str: """The canonical name (resolved one, not alias name).""" + if self.parent is None: + return self.name if isinstance(self.parent, ExprName): return f"{self.parent.canonical_path}.{self.name}" if isinstance(self.parent, str): return f"{self.parent}.{self.name}" - if self.annotation_scope is not None: - try: - if not isinstance(self.annotation_scope, str): - return self.annotation_scope.resolve_annotation(self.name) - if self.parent is not None: - return self.parent.modules_collection[self.annotation_scope].resolve_annotation(self.name) - except NameResolutionError: - pass - elif self.parent is not None: - try: - return self.parent.resolve(self.name) - except NameResolutionError: - pass - return self.name + try: + return self.parent.resolve(self.name) + except NameResolutionError: + return self.name @property def resolved(self) -> Module | Class | None: @@ -683,17 +673,6 @@ def is_enum_value(self) -> bool: except Exception: # noqa: BLE001 return False - @property - def is_type_parameter(self) -> bool: - """Whether this name resolves to a type parameter.""" - return ":" in self.canonical_path and "." not in self.canonical_path.partition(":")[2] - - def as_dict(self, **kwargs: Any) -> dict[str, Any]: - base = super(type(self), self).as_dict(**kwargs) - if self.annotation_scope is not None and not isinstance(self.annotation_scope, str): - base["annotation_scope"] = self.annotation_scope.path - return base - # YORE: EOL 3.9: Replace `**_dataclass_opts` with `slots=True` within line. @dataclass(eq=True, **_dataclass_opts) @@ -1090,13 +1069,8 @@ def _build_listcomp(node: ast.ListComp, parent: Module | Class, **kwargs: Any) - return ExprListComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators]) -def _build_name( - node: ast.Name, - parent: Module | Class, - annotation_scope: Function | Class | TypeAlias | None = None, - **kwargs: Any, # noqa: ARG001 -) -> Expr: - return ExprName(node.id, parent, annotation_scope=annotation_scope) +def _build_name(node: ast.Name, parent: Module | Class, **kwargs: Any) -> Expr: # noqa: ARG001 + return ExprName(node.id, parent) def _build_named_expr(node: ast.NamedExpr, parent: Module | Class, **kwargs: Any) -> Expr: @@ -1215,7 +1189,6 @@ def get_expression( parent: Module | Class, *, parse_strings: bool | None = None, - annotation_scope: Function | Class | TypeAlias | None = None, ) -> Expr | None: """Build an expression from an AST. @@ -1236,7 +1209,7 @@ def get_expression( parse_strings = False else: parse_strings = not module.imports_future_annotations - return _build(node, parent, parse_strings=parse_strings, annotation_scope=annotation_scope) + return _build(node, parent, parse_strings=parse_strings) def safe_get_expression( @@ -1244,7 +1217,6 @@ def safe_get_expression( parent: Module | Class, *, parse_strings: bool | None = None, - annotation_scope: Function | Class | TypeAlias | None = None, log_level: LogLevel | None = LogLevel.error, msg_format: str = "{path}:{lineno}: Failed to get expression from {node_class}: {error}", ) -> Expr | None: @@ -1262,7 +1234,7 @@ def safe_get_expression( A string or resovable name or expression. """ try: - return get_expression(node, parent, parse_strings=parse_strings, annotation_scope=annotation_scope) + return get_expression(node, parent, parse_strings=parse_strings) except Exception as error: # noqa: BLE001 if log_level is None: return None @@ -1288,7 +1260,7 @@ def safe_get_expression( get_base_class = partial(get_expression, parse_strings=False) safe_get_base_class = partial( safe_get_expression, - parse_strings=None, + parse_strings=False, msg_format=_msg_format % "base class", ) get_condition = partial(get_expression, parse_strings=False) diff --git a/src/_griffe/models.py b/src/_griffe/models.py index 2b47d0e72..a1ecab1d5 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -231,7 +231,7 @@ def __init__( """The parameter default value.""" self.docstring: Docstring | None = docstring """The parameter docstring.""" - # The parent function is set in `Function.parameters`, + # The parent function is set in `Function.__init__`, # when the parameters are assigned to the function. self.function: Function | None = None """The parent function of the parameter.""" @@ -1165,40 +1165,6 @@ def resolve(self, name: str) -> str: # Recurse in parent. return self.parent.resolve(name) - def resolve_annotation(self, name: str) -> str: - """Resolve a name within this object's annotation scope. - - Parameters: - name: The name to resolve. - - Raises: - NameResolutionError: When the name could not be resolved. - - Returns: - The resolved name, pointing to either an object or a type parameter. - """ - if name in self.type_parameters: - type_parameter = self.type_parameters[name] - if type_parameter.kind == TypeParameterKind.type_var: - prefix = "" - elif type_parameter.kind == TypeParameterKind.type_var_tuple: - prefix = "*" - elif type_parameter.kind == TypeParameterKind.param_spec: - prefix = "**" - return f"{self.path}:{prefix}{name}" - - if self.parent: - if name in self.parent.members: - if self.parent.members[name].is_alias: - return self.parent.members[name].target_path # type: ignore[union-attr] - return self.parent.members[name].path - if self.parent.is_class and name == self.parent.name: - return self.parent.canonical_path - if not self.parent.is_module: - return self.parent.resolve_annotation(name) - - raise NameResolutionError(f"{name} could not be resolved in the scope of {self.path}") - def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: """Return this object's data as a dictionary. @@ -1768,20 +1734,6 @@ def resolve(self, name: str) -> str: """ return self.final_target.resolve(name) - def resolve_annotation(self, name: str) -> str: - """Resolve a name within this object's annotation scope. - - Parameters: - name: The name to resolve. - - Raises: - NameResolutionError: When the name could not be resolved. - - Returns: - The resolved name, pointing to either an object or a type parameter. - """ - return self.final_target.resolve_annotation(name) - # SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE/TYPE ALIAS PROXIES --------------- # These methods and properties exist on targets of specific kind. # We first try to reach the final target, triggering alias resolution errors @@ -2323,7 +2275,8 @@ def __init__( **kwargs: See [`griffe.Object`][]. """ super().__init__(*args, **kwargs) - self.parameters = parameters or Parameters() + self.parameters: Parameters = parameters or Parameters() + """The function parameters.""" self.returns: str | Expr | None = returns """The function return type annotation.""" self.decorators: list[Decorator] = decorators or [] @@ -2331,16 +2284,7 @@ def __init__( self.overloads: list[Function] | None = None """The overloaded signatures of this function.""" - @property - def parameters(self) -> Parameters: - """The function parameters.""" - return self._parameters - - @parameters.setter - def parameters(self, parameters: Parameters) -> None: - self._parameters = parameters - - for parameter in self._parameters: + for parameter in self.parameters: parameter.function = self @property @@ -2440,7 +2384,7 @@ class TypeAlias(Object): def __init__( self, *args: Any, - value: str | Expr | None = None, + value: str | Expr | None, **kwargs: Any, ) -> None: """Initialize the function. diff --git a/tests/test_expressions.py b/tests/test_expressions.py index 581baf54b..0f43fca72 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -3,7 +3,6 @@ from __future__ import annotations import ast -import sys import pytest @@ -76,29 +75,6 @@ def test_resolving_full_names() -> None: assert module["attribute2"].annotation.canonical_path == "package.module.Class" -# YORE: EOL 3.11: Remove line. -@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") -def test_resolving_type_parameters() -> None: - """Assert type parameters correctly transformed to their fully-resolved form.""" - with temporary_visited_module( - """ - class C[T]: - class D[T]: - def func[Y](self, arg1: T, arg2: Y): pass - attr: T - - def func[Z](arg1: T, arg2: Y): pass - """, - ) as module: - assert module["C.D.func"].parameters["arg1"].annotation.canonical_path == "module.C.D:T" - assert module["C.D.func"].parameters["arg2"].annotation.canonical_path == "module.C.D.func:Y" - - assert module["C.D.attr"].annotation.canonical_path == "module.C.D:T" - - assert module["C.func"].parameters["arg1"].annotation.canonical_path == "module.C:T" - assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y" - - @pytest.mark.parametrize("code", syntax_examples) def test_expressions(code: str) -> None: """Test building annotations from AST nodes. diff --git a/tests/test_models.py b/tests/test_models.py index 63baa31b5..a1b68fcb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from copy import deepcopy from textwrap import dedent @@ -500,31 +499,6 @@ def method(self): assert module["Class.method"].resolve("instance_attribute") == "module.Class.instance_attribute" -# YORE: EOL 3.11: Remove line. -@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") -def test_annotation_resolution() -> None: - """Names are correctly resolved in the annotation scope of an object.""" - with temporary_visited_module( - """ - class C[T]: - class D[T]: - def func[Y](self, arg1: T, arg2: Y): pass - - def func[Z](arg1: T, arg2: Y): pass - """, - ) as module: - assert module["C.D"].resolve_annotation("T") == "module.C.D:T" - - assert module["C.D.func"].resolve_annotation("T") == "module.C.D:T" - assert module["C.D.func"].resolve_annotation("Y") == "module.C.D.func:Y" - - assert module["C"].resolve_annotation("T") == "module.C:T" - - assert module["C.func"].resolve_annotation("T") == "module.C:T" - with pytest.raises(NameResolutionError): - module["C.func"].resolve_annotation("Y") - - def test_set_parameters() -> None: """We can set parameters.""" parameters = Parameters() From 4a1c1ce9cd99f8cafedf31350cb3dd1480d3a704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 17 Mar 2025 12:47:23 +0100 Subject: [PATCH 5/8] Resolve type parameters Co-authored-by: Victor Westerhuis --- src/_griffe/agents/inspector.py | 47 ++++++++++++++++++++++----------- src/_griffe/agents/visitor.py | 35 +++++++++++++++--------- src/_griffe/expressions.py | 24 ++++++++++++----- src/_griffe/models.py | 18 ++++++++++--- tests/test_expressions.py | 23 ++++++++++++++++ tests/test_models.py | 25 ++++++++++++++++++ 6 files changed, 133 insertions(+), 39 deletions(-) diff --git a/src/_griffe/agents/inspector.py b/src/_griffe/agents/inspector.py index 912f944d9..5fbc2225a 100644 --- a/src/_griffe/agents/inspector.py +++ b/src/_griffe/agents/inspector.py @@ -339,7 +339,7 @@ def inspect_class(self, node: ObjectNode) -> None: name=node.name, docstring=self._get_docstring(node), bases=bases, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, parent=self.current, member=node.name)), lineno=lineno, endlineno=endlineno, ) @@ -459,13 +459,16 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: returns = None else: parameters = Parameters( - *[_convert_parameter(parameter, parent=self.current) for parameter in signature.parameters.values()], + *[ + _convert_parameter(parameter, parent=self.current, member=node.name) + for parameter in signature.parameters.values() + ], ) return_annotation = signature.return_annotation returns = ( None if return_annotation is _empty - else _convert_object_to_annotation(return_annotation, parent=self.current) + else _convert_object_to_annotation(return_annotation, parent=self.current, member=node.name) ) lineno, endlineno = self._get_linenos(node) @@ -486,7 +489,9 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: name=node.name, parameters=parameters, returns=returns, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), + type_parameters=TypeParameters( + *_convert_type_parameters(node.obj, parent=self.current, member=node.name), + ), docstring=self._get_docstring(node), lineno=lineno, endlineno=endlineno, @@ -512,10 +517,10 @@ def inspect_type_alias(self, node: ObjectNode) -> None: type_alias = TypeAlias( name=node.name, - value=_convert_type_to_annotation(node.obj.__value__, self.current), + value=_convert_type_to_annotation(node.obj.__value__, parent=self.current, member=node.name), lineno=lineno, endlineno=endlineno, - type_parameters=TypeParameters(*_convert_type_parameters(node.obj, self.current)), + type_parameters=TypeParameters(*_convert_type_parameters(node.obj, parent=self.current, member=node.name)), docstring=self._get_docstring(node), parent=self.current, ) @@ -588,10 +593,17 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Expr | None = Non } -def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> Parameter: +def _convert_parameter( + parameter: SignatureParameter, + *, + parent: Module | Class, + member: str | None = None, +) -> Parameter: name = parameter.name annotation = ( - None if parameter.annotation is _empty else _convert_object_to_annotation(parameter.annotation, parent=parent) + None + if parameter.annotation is _empty + else _convert_object_to_annotation(parameter.annotation, parent=parent, member=member) ) kind = _parameter_kind_map[parameter.kind] if parameter.default is _empty: @@ -604,7 +616,7 @@ def _convert_parameter(parameter: SignatureParameter, parent: Module | Class) -> return Parameter(name, annotation=annotation, kind=kind, default=default) -def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: +def _convert_object_to_annotation(obj: Any, *, parent: Module | Class, member: str | None = None) -> str | Expr | None: # even when *we* import future annotations, # the object from which we get a signature # can come from modules which did *not* import them, @@ -621,7 +633,7 @@ def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Exp annotation_node = compile(obj, mode="eval", filename="<>", flags=ast.PyCF_ONLY_AST, optimize=2) except SyntaxError: return obj - return safe_get_annotation(annotation_node.body, parent=parent) # type: ignore[attr-defined] + return safe_get_annotation(annotation_node.body, parent, member=member) # type: ignore[attr-defined] _type_parameter_kind_map = { @@ -638,7 +650,9 @@ def _convert_object_to_annotation(obj: Any, parent: Module | Class) -> str | Exp def _convert_type_parameters( obj: Any, + *, parent: Module | Class, + member: str | None = None, ) -> list[TypeParameter]: obj = unwrap(obj) @@ -649,9 +663,9 @@ def _convert_type_parameters( for type_parameter in obj.__type_params__: bound = getattr(type_parameter, "__bound__", None) if bound is not None: - bound = _convert_type_to_annotation(bound, parent=parent) + bound = _convert_type_to_annotation(bound, parent=parent, member=member) constraints: list[str | Expr] = [ - _convert_type_to_annotation(constraint, parent=parent) # type: ignore[misc] + _convert_type_to_annotation(constraint, parent=parent, member=member) # type: ignore[misc] for constraint in getattr(type_parameter, "__constraints__", ()) ] @@ -659,6 +673,7 @@ def _convert_type_parameters( default = _convert_type_to_annotation( type_parameter.__default__, parent=parent, + member=member, ) else: default = None @@ -676,14 +691,14 @@ def _convert_type_parameters( return type_parameters -def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr | None: +def _convert_type_to_annotation(obj: Any, *, parent: Module | Class, member: str | None = None) -> str | Expr | None: origin = typing.get_origin(obj) if origin is None: - return _convert_object_to_annotation(obj, parent=parent) + return _convert_object_to_annotation(obj, parent=parent, member=member) args: Sequence[str | Expr | None] = [ - _convert_type_to_annotation(arg, parent=parent) for arg in typing.get_args(obj) + _convert_type_to_annotation(arg, parent=parent, member=member) for arg in typing.get_args(obj) ] # YORE: EOL 3.9: Replace block with lines 2-3. @@ -691,7 +706,7 @@ def _convert_type_to_annotation(obj: Any, parent: Module | Class) -> str | Expr if origin is types.UnionType: return functools.reduce(lambda left, right: ExprBinOp(left, "|", right), args) # type: ignore[arg-type] - origin = _convert_type_to_annotation(origin, parent=parent) + origin = _convert_type_to_annotation(origin, parent=parent, member=member) if origin is None: return None diff --git a/src/_griffe/agents/visitor.py b/src/_griffe/agents/visitor.py index 73fe96ba2..61bf1d90f 100644 --- a/src/_griffe/agents/visitor.py +++ b/src/_griffe/agents/visitor.py @@ -215,22 +215,30 @@ def _get_docstring(self, node: ast.AST, *, strict: bool = False) -> Docstring | def _get_type_parameters( self, - statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias, + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias, + *, + scope: str | None = None, ) -> list[TypeParameter]: return [ TypeParameter( type_param.name, # type: ignore[attr-defined] kind=self._type_parameter_kind_map[type(type_param)], - bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current), - default=safe_get_annotation(getattr(type_param, "default_value", None), parent=self.current), + bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current, member=scope), + default=safe_get_annotation( + getattr(type_param, "default_value", None), + parent=self.current, + member=scope, + ), ) - for type_param in statement.type_params + for type_param in node.type_params ] else: def _get_type_parameters( self, - _statement: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, # noqa: ARG002, + *, + scope: str | None = None, # noqa: ARG002, ) -> list[TypeParameter]: return [] @@ -318,7 +326,7 @@ def visit_classdef(self, node: ast.ClassDef) -> None: lineno = node.lineno # handle base classes - bases = [safe_get_base_class(base, parent=self.current) for base in node.bases] + bases = [safe_get_base_class(base, parent=self.current, member=node.name) for base in node.bases] class_ = Class( name=node.name, @@ -326,11 +334,12 @@ def visit_classdef(self, node: ast.ClassDef) -> None: endlineno=node.end_lineno, docstring=self._get_docstring(node), decorators=decorators, - type_parameters=TypeParameters(*self._get_type_parameters(node)), + type_parameters=TypeParameters(*self._get_type_parameters(node, scope=node.name)), bases=bases, # type: ignore[arg-type] runtime=not self.type_guarded, ) 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_, agent=self) @@ -419,7 +428,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: attribute = Attribute( name=node.name, value=None, - annotation=safe_get_annotation(node.returns, parent=self.current), + annotation=safe_get_annotation(node.returns, parent=self.current, member=node.name), lineno=node.lineno, endlineno=node.end_lineno, docstring=self._get_docstring(node), @@ -437,7 +446,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: Parameter( name, kind=kind, - annotation=safe_get_annotation(annotation, parent=self.current), + annotation=safe_get_annotation(annotation, parent=self.current, member=node.name), default=default if isinstance(default, str) else safe_get_expression(default, parent=self.current, parse_strings=False), @@ -451,9 +460,9 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: lineno=lineno, endlineno=node.end_lineno, parameters=parameters, - returns=safe_get_annotation(node.returns, parent=self.current), + returns=safe_get_annotation(node.returns, parent=self.current, member=node.name), decorators=decorators, - type_parameters=TypeParameters(*self._get_type_parameters(node)), + type_parameters=TypeParameters(*self._get_type_parameters(node, scope=node.name)), docstring=self._get_docstring(node), runtime=not self.type_guarded, parent=self.current, @@ -520,7 +529,7 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: name = node.name.id - value = safe_get_expression(node.value, parent=self.current) + value = safe_get_expression(node.value, parent=self.current, member=name) try: docstring = self._get_docstring(ast_next(node), strict=True) @@ -532,7 +541,7 @@ def visit_typealias(self, node: ast.TypeAlias) -> None: value=value, lineno=node.lineno, endlineno=node.end_lineno, - type_parameters=TypeParameters(*self._get_type_parameters(node)), + type_parameters=TypeParameters(*self._get_type_parameters(node, scope=name)), docstring=docstring, parent=self.current, ) diff --git a/src/_griffe/expressions.py b/src/_griffe/expressions.py index ac4a57b49..5e328b4b5 100644 --- a/src/_griffe/expressions.py +++ b/src/_griffe/expressions.py @@ -601,6 +601,8 @@ class ExprName(Expr): """Actual name.""" parent: str | ExprName | Module | Class | None = None """Parent (for resolution in its scope).""" + member: str | None = None + """Member name (for resolution in its scope).""" def __eq__(self, other: object) -> bool: """Two name expressions are equal if they have the same `name` value (`parent` is ignored).""" @@ -632,8 +634,9 @@ def canonical_path(self) -> str: return f"{self.parent.canonical_path}.{self.name}" if isinstance(self.parent, str): return f"{self.parent}.{self.name}" + parent = self.parent.members.get(self.member, self.parent) # type: ignore[arg-type] try: - return self.parent.resolve(self.name) + return parent.resolve(self.name) except NameResolutionError: return self.name @@ -673,6 +676,11 @@ def is_enum_value(self) -> bool: except Exception: # noqa: BLE001 return False + @property + def is_type_parameter(self) -> bool: + """Whether this name resolves to a type parameter.""" + return "[" in self.canonical_path + # YORE: EOL 3.9: Replace `**_dataclass_opts` with `slots=True` within line. @dataclass(eq=True, **_dataclass_opts) @@ -1069,8 +1077,8 @@ def _build_listcomp(node: ast.ListComp, parent: Module | Class, **kwargs: Any) - return ExprListComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators]) -def _build_name(node: ast.Name, parent: Module | Class, **kwargs: Any) -> Expr: # noqa: ARG001 - return ExprName(node.id, parent) +def _build_name(node: ast.Name, parent: Module | Class, member: str | None = None, **kwargs: Any) -> Expr: # noqa: ARG001 + return ExprName(node.id, parent, member) def _build_named_expr(node: ast.NamedExpr, parent: Module | Class, **kwargs: Any) -> Expr: @@ -1180,7 +1188,7 @@ def _build_yield_from(node: ast.YieldFrom, parent: Module | Class, **kwargs: Any } -def _build(node: ast.AST, parent: Module | Class, **kwargs: Any) -> Expr: +def _build(node: ast.AST, parent: Module | Class, /, **kwargs: Any) -> Expr: return _node_map[type(node)](node, parent, **kwargs) @@ -1188,6 +1196,7 @@ def get_expression( node: ast.AST | None, parent: Module | Class, *, + member: str | None = None, parse_strings: bool | None = None, ) -> Expr | None: """Build an expression from an AST. @@ -1195,6 +1204,7 @@ def get_expression( Parameters: node: The annotation node. parent: The parent used to resolve the name. + member: The member name (for resolution in its scope). parse_strings: Whether to try and parse strings as type annotations. Returns: @@ -1209,13 +1219,14 @@ def get_expression( parse_strings = False else: parse_strings = not module.imports_future_annotations - return _build(node, parent, parse_strings=parse_strings) + return _build(node, parent, member=member, parse_strings=parse_strings) def safe_get_expression( node: ast.AST | None, parent: Module | Class, *, + member: str | None = None, parse_strings: bool | None = None, log_level: LogLevel | None = LogLevel.error, msg_format: str = "{path}:{lineno}: Failed to get expression from {node_class}: {error}", @@ -1225,6 +1236,7 @@ def safe_get_expression( Parameters: node: The annotation node. parent: The parent used to resolve the name. + member: The member name (for resolution in its scope). parse_strings: Whether to try and parse strings as type annotations. log_level: Log level to use to log a message. None to disable logging. msg_format: A format string for the log message. Available placeholders: @@ -1234,7 +1246,7 @@ def safe_get_expression( A string or resovable name or expression. """ try: - return get_expression(node, parent, parse_strings=parse_strings) + return get_expression(node, parent, member=member, parse_strings=parse_strings) except Exception as error: # noqa: BLE001 if log_level is None: return None diff --git a/src/_griffe/models.py b/src/_griffe/models.py index a1ecab1d5..d0a9baf84 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -1147,15 +1147,25 @@ def resolve(self, name: str) -> str: # TODO: Better match Python's own scoping rules? # Also, maybe return regular paths instead of canonical ones? - # Name is a member this object. + # Name is a type parameter. + if name in self.type_parameters: + type_parameter = self.type_parameters[name] + if type_parameter.kind is TypeParameterKind.type_var_tuple: + prefix = "*" + elif type_parameter.kind is TypeParameterKind.param_spec: + prefix = "**" + else: + prefix = "" + return f"{self.path}[{prefix}{name}]" + + # Name is a member of this object. if name in self.members: if self.members[name].is_alias: return self.members[name].target_path # type: ignore[union-attr] return self.members[name].path - # Name unknown and no more parent scope. + # Name unknown and no more parent scope. Could be a built-in. if self.parent is None: - # could be a built-in raise NameResolutionError(f"{name} could not be resolved in the scope of {self.path}") # Name is parent, non-module object. @@ -2384,7 +2394,7 @@ class TypeAlias(Object): def __init__( self, *args: Any, - value: str | Expr | None, + value: str | Expr | None = None, **kwargs: Any, ) -> None: """Initialize the function. diff --git a/tests/test_expressions.py b/tests/test_expressions.py index 0f43fca72..4fcdda322 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import sys import pytest @@ -110,3 +111,25 @@ def __init__(self, x: int): """, ) as module: assert module["Class.x"].value.canonical_path == "module.Class(x)" + + +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_resolving_type_parameters() -> None: + """Assert type parameters are correctly transformed to their fully-resolved form.""" + with temporary_visited_module( + """ + class C[T]: + class D[T]: + def func[Y](self, arg1: T, arg2: Y): pass + attr: T + def func[Z](arg1: T, arg2: Y): pass + """, + ) as module: + assert module["C.D.func"].parameters["arg1"].annotation.canonical_path == "module.C.D[T]" + assert module["C.D.func"].parameters["arg2"].annotation.canonical_path == "module.C.D.func[Y]" + + assert module["C.D.attr"].annotation.canonical_path == "module.C.D[T]" + + assert module["C.func"].parameters["arg1"].annotation.canonical_path == "module.C[T]" + assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y" diff --git a/tests/test_models.py b/tests/test_models.py index a1b68fcb4..1130c8a9d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from copy import deepcopy from textwrap import dedent @@ -563,3 +564,27 @@ def test_delete_type_parameters() -> None: del type_parameters[0] assert "x" not in type_parameters assert len(type_parameters) == 0 + + +# YORE: EOL 3.11: Remove line. +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics") +def test_annotation_resolution() -> None: + """Names are correctly resolved in the annotation scope of an object.""" + with temporary_visited_module( + """ + class C[T]: + class D[T]: + def func[Y](self, arg1: T, arg2: Y): pass + def func[Z](arg1: T, arg2: Y): pass + """, + ) as module: + assert module["C.D"].resolve("T") == "module.C.D[T]" + + assert module["C.D.func"].resolve("T") == "module.C.D[T]" + assert module["C.D.func"].resolve("Y") == "module.C.D.func[Y]" + + assert module["C"].resolve("T") == "module.C[T]" + + assert module["C.func"].resolve("T") == "module.C[T]" + with pytest.raises(NameResolutionError): + module["C.func"].resolve("Y") From 6723ee12d40b6d15ec6d9ae1433db81618e4b6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 17 Mar 2025 13:57:31 +0100 Subject: [PATCH 6/8] style: Format --- src/_griffe/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_griffe/models.py b/src/_griffe/models.py index 9551b33dd..22445cdef 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -1858,7 +1858,7 @@ def deleter(self) -> Function | None: @property def value(self) -> str | Expr | None: """The attribute or type alias value.""" - return cast(Union[Attribute, TypeAlias], self.final_target).value + return cast("Union[Attribute, TypeAlias]", self.final_target).value @property def annotation(self) -> str | Expr | None: From 96cbcf511bc33c2339d606137f689d55731c6d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 28 Jul 2025 19:23:15 +0200 Subject: [PATCH 7/8] chore: Format --- tests/test_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index 48906b08b..e97215550 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -598,7 +598,6 @@ def test_building_function_and_class_signatures() -> None: assert func.signature() == expected - def test_set_type_parameters() -> None: """We can set type parameters.""" type_parameters = TypeParameters() From b70af2c807f636725bb49f45396bfad6cea605fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 28 Jul 2025 19:28:37 +0200 Subject: [PATCH 8/8] chore: Remove unusued warning --- src/_griffe/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_griffe/models.py b/src/_griffe/models.py index f29a06b24..bed1553ce 100644 --- a/src/_griffe/models.py +++ b/src/_griffe/models.py @@ -410,7 +410,7 @@ def __init__( """The type parameter bound or constraints.""" if constraints: - self.constraints = constraints # type: ignore[assignment] + self.constraints = constraints else: self.bound = bound