Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pydantic plugins - entry-point support #6820

Merged
merged 62 commits into from Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
04d8592
Support pydantic entry point
Kludex Jul 18, 2023
a69666b
bump to fix ci
samuelcolvin Jul 18, 2023
a0ccb75
change implementation
Kludex Jul 19, 2023
4cf0a6e
Fix linter
Kludex Jul 20, 2023
433ced4
Fix linter
Kludex Jul 22, 2023
ad9bb5e
Remove print
Kludex Jul 24, 2023
ed9f257
Fix type
Kludex Jul 24, 2023
5c46c89
Use PEP621 way instead of poetry
Kludex Jul 24, 2023
89c75cc
Use code fences on docstring example
Kludex Jul 24, 2023
7b14ed2
Override method with the same name on PluggableSchemaValidator
Kludex Jul 24, 2023
f0224ea
Use `schema_validator_cls` function to define which `SchemaValidator`…
Kludex Jul 24, 2023
859714f
Skip test on docstring
Kludex Jul 24, 2023
afba5b8
Add documentation
Kludex Jul 24, 2023
b4e044d
Use class-base implementation
Kludex Jul 25, 2023
4235040
Ignore NotImplementedError
Kludex Jul 25, 2023
47586e8
Add `plugin_settings` to `ConfigDict`
Kludex Jul 25, 2023
2703daa
Use `_ignored` field
Kludex Jul 25, 2023
10da22a
Update documentation
Kludex Jul 25, 2023
8ab61e5
Fix type on create_schema_validator
Kludex Jul 25, 2023
e545317
Add documentation
Kludex Jul 26, 2023
2005d79
Use `Final` from `typing-extensions`
Kludex Jul 26, 2023
a641fe2
Add a line on the example on `plugins.md`
Kludex Jul 26, 2023
2d04bc2
Use the right API reference for the plugins
Kludex Jul 26, 2023
9644a2d
Fix API reference on `plugins.md`
Kludex Jul 26, 2023
9c0b01e
Fix link to API documentation
Kludex Jul 26, 2023
23c9d81
Add tests
Kludex Jul 26, 2023
887ca5d
Fix docstring on _loader
Kludex Jul 26, 2023
00fd68b
Add pdm info and pdm list
Kludex Jul 26, 2023
adc31dd
Unlikely to conflict
Kludex Jul 26, 2023
cab2375
✅ Fix tests after a rebase
lig Aug 22, 2023
d427529
🚑 Fix recursion error if pydantic is imported in a plugin
lig Aug 22, 2023
bcbb9a9
💡 Fix docs build on CI
lig Aug 22, 2023
6ad2775
🚨 Make mypy tests happy
lig Aug 22, 2023
4689ffb
🔧 Introduce `defer_import` plugin extra argument
lig Aug 23, 2023
7ea9ab9
👽 Use defered import for all plugins
lig Aug 23, 2023
6fa40cf
🔊 Expose plugin loading error on `plugins` import
lig Aug 24, 2023
e02d1c5
✨ Create plugin event handler instances for each event (#7243)
lig Aug 29, 2023
0aac537
🚨 Update usage docs link for plugins
lig Aug 29, 2023
9f58041
🏷 Fix event callbacks type
lig Sep 8, 2023
4c6c9e2
🚧 Reproduce circular import in the plugin module
lig Sep 11, 2023
f21c926
🐛 Suppress circular import errors while executing plugins
lig Sep 11, 2023
2581cdb
💡 Add warning on import error when loading plugins
lig Sep 12, 2023
abfffd8
📝 Update docs and add experimental feature banners
lig Sep 12, 2023
df4b2a3
✅ Skip randomly failing test on Python 3.9
lig Sep 12, 2023
e43b40f
👌 Address most of code review comments
lig Sep 13, 2023
e79ff4a
♻ Refactor `PluggableSchemaValidator`
lig Sep 13, 2023
abe836b
🔇 Suppress all exceptions while running a plugin
lig Sep 15, 2023
480b678
🚑 Fix crash when only one of two handlers provided in a plugin
lig Sep 15, 2023
f3453d1
♻ Make `Plugin` class into `PluginProtocol` class
lig Sep 15, 2023
e5802ba
📝 Merge plugin docs into single document in the Usage section
lig Sep 15, 2023
1e93da5
🚑 Suppress ImportError only while running plugin callbacks
lig Sep 18, 2023
4587cfd
confirm that repr(model) works before validation
samuelcolvin Sep 22, 2023
9b1ed15
refactor plugin
samuelcolvin Sep 22, 2023
ec32d59
Merge branch 'main' into kludex/PYD-181
samuelcolvin Sep 22, 2023
74e2ff1
simplify call method
samuelcolvin Sep 22, 2023
c3990bb
fixing tests
samuelcolvin Sep 22, 2023
56aab5b
fix docs
samuelcolvin Sep 22, 2023
49d72a5
Merge branch 'main' into kludex/PYD-181
samuelcolvin Sep 22, 2023
fce3acf
skip plugin tests by default
samuelcolvin Sep 22, 2023
9fa2da0
fix docs
samuelcolvin Sep 22, 2023
1f07b42
docs improvements and more tests
samuelcolvin Sep 22, 2023
e073168
📝 Fix some docs and doc strings
lig Sep 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Expand Up @@ -178,6 +178,29 @@ jobs:
- name: test
run: make test-fastapi

test-plugin:
name: test pydantic plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- uses: pdm-project/setup-pdm@v3
with:
python-version: '3.11'
cache: true

- name: install deps
run: |
pdm use -f $PYTHON
pdm install -G testing

- name: install example plugin
run: pdm add ./tests/plugin

- run: pdm run pytest ./tests/plugin
env:
TEST_PLUGIN: 1

test-mypy:
name: mypy ${{ matrix.mypy-version }} / ${{ matrix.python-version }}
runs-on: ubuntu-latest
Expand Down Expand Up @@ -340,6 +363,7 @@ jobs:
- test-memray
- test-mypy
- test-fastapi
- test-plugin

runs-on: ubuntu-latest

Expand Down
5 changes: 5 additions & 0 deletions docs/api/plugin.md
@@ -0,0 +1,5 @@
!!! warning "Experimental feature"
Plugins support is experimental and is subject to change in minor releases.
Developing plugins is not recommended until the feature becomes stable.

::: pydantic.plugin
113 changes: 113 additions & 0 deletions docs/usage/plugins.md
@@ -0,0 +1,113 @@
!!! warning "Experimental feature"
Plugins support is experimental and is subject to change in minor releases.
Developing plugins is not recommended until the feature becomes stable.

Pydantic allows users to create plugins that can be used to extend the functionality of the library.

Plugins are installed via Python entry points. You can read more about entry points in the
[Entry points specification](https://packaging.python.org/specifications/entry-points/) from the
Python Packaging Authority.

In case you have a project called `my-pydantic-plugin`, you can create a plugin by adding the following
to your `pyproject.toml`:

```toml
[project.entry-points.pydantic]
my_plugin = "my_pydantic_plugin:plugin"
```

The entry point group is `pydantic`, `my_plugin` is the name of the plugin, `my_pydantic_plugin` is the module to load plugin object from, and `plugin` is the object name to load.

Plugins are loaded in the order they are found, and the order they are found is not guaranteed.

As a user, you can modify the behavior of the plugin in a `BaseModel` using the `plugin_settings` [Model Config](../usage/model_config.md) argument or
class keyword argument. This argument takes a dictionary of settings that will be passed to all plugins as is.
The plugin can then use these settings to modify its behavior. It is recommended for plugins to separate their settings
into their own dedicates keys in a plugin specific key in the `plugin_settings` dictionary.

```py test="skip"
from pydantic import BaseModel


class Foo(BaseModel, plugin_settings={'my-plugin': {'observe': 'all'}}):
...
```

## Build a plugin

??? api "API Documentation"
[`pydantic.plugin`][pydantic.plugin]<br>

Pydantic provides an API for creating plugins. The API is exposed via the `pydantic.plugin` module.

On your plugin you can _wrap_ the following methods:

* [`validate_python`][pydantic_core.SchemaValidator.validate_python]: Used to validate the data from a Python object.
* [`validate_json`][pydantic_core.SchemaValidator.validate_json]: Used to validate the data from a JSON string.
* [`validate_strings`][pydantic_core.SchemaValidator.validate_strings]: Used to validate the data from strings.

For each method, you can implement the following callbacks:

* `on_enter`: Called before the validation of a field starts.
* `on_success`: Called when the validation of a field succeeds.
* `on_error`: Called when the validation of a field fails.

Let's see an example of a plugin that _wraps_ the `validate_python` method of the [`SchemaValidator`][pydantic_core.SchemaValidator].

```py
from typing import Any, Dict, Optional, Union

from pydantic_core import CoreConfig, CoreSchema, ValidationError

from pydantic.plugin import (
NewSchemaReturns,
PydanticPluginProtocol,
ValidatePythonHandlerProtocol,
)


class OnValidatePython(ValidatePythonHandlerProtocol):
def on_enter(
self,
input: Any,
*,
strict: Optional[bool] = None,
from_attributes: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
self_instance: Optional[Any] = None,
) -> None:
print(input)

def on_success(self, result: Any) -> None:
print(result)

def on_error(self, error: ValidationError) -> None:
print(error.json())


class Plugin(PydanticPluginProtocol):
def new_schema_validator(
self,
schema: CoreSchema,
config: Union[CoreConfig, None],
plugin_settings: Dict[str, object],
) -> NewSchemaReturns:
return OnValidatePython(), None, None


plugin = Plugin()
```

## Using Plugin Settings

Consider that you have a plugin called setting called "observer", then you can use it like this:

```py
from pydantic import BaseModel


class Foo(BaseModel, plugin_settings={'observer': 'all'}):
...
```

On each validation call, the `plugin_settings` will be passed to a callable registered for the events.
2 changes: 2 additions & 0 deletions mkdocs.yml
Expand Up @@ -96,6 +96,7 @@ nav:
- 'Strict Mode': usage/strict_mode.md
- 'Conversion Table': usage/conversion_table.md
- 'Settings Management': usage/pydantic_settings.md
- 'Pydantic Plugins': usage/plugins.md
- Examples:
- Secrets: examples/secrets.md
- Error Messages:
Expand Down Expand Up @@ -130,6 +131,7 @@ nav:
- 'Pydantic Types': api/types.md
- 'Network Types': api/networks.md
- 'Version Information': api/version.md
- 'Pydantic Plugins': api/plugin.md
- Pydantic Core:
- 'pydantic_core': api/pydantic_core.md
- 'pydantic_core.core_schema': api/pydantic_core_schema.md
Expand Down
2 changes: 2 additions & 0 deletions pydantic/_internal/_config.py
Expand Up @@ -75,6 +75,7 @@ class ConfigWrapper:
protected_namespaces: tuple[str, ...]
hide_input_in_errors: bool
defer_build: bool
plugin_settings: dict[str, object] | None
schema_generator: type[GenerateSchema] | None
json_schema_serialization_defaults_required: bool
json_schema_mode_override: Literal['validation', 'serialization', None]
Expand Down Expand Up @@ -242,6 +243,7 @@ def _context_manager() -> Iterator[None]:
hide_input_in_errors=False,
json_encoders=None,
defer_build=False,
plugin_settings=None,
schema_generator=None,
json_schema_serialization_defaults_required=False,
json_schema_mode_override=None,
Expand Down
6 changes: 5 additions & 1 deletion pydantic/_internal/_dataclasses.py
Expand Up @@ -20,6 +20,7 @@

from ..errors import PydanticUndefinedAnnotation
from ..fields import FieldInfo
from ..plugin._schema_validator import create_schema_validator
from ..warnings import PydanticDeprecatedSince20
from . import _config, _decorators, _discriminated_union, _typing_extra
from ._core_utils import collect_invalid_schemas, simplify_schema_references, validate_core_schema
Expand Down Expand Up @@ -168,8 +169,11 @@ def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -
# __pydantic_decorators__ and __pydantic_fields__ should already be set
cls = typing.cast('type[PydanticDataclass]', cls)
# debug(schema)

cls.__pydantic_core_schema__ = schema = validate_core_schema(schema)
cls.__pydantic_validator__ = validator = SchemaValidator(schema, core_config)
cls.__pydantic_validator__ = validator = create_schema_validator(
schema, core_config, config_wrapper.plugin_settings
)
cls.__pydantic_serializer__ = SchemaSerializer(schema, core_config)

if config_wrapper.validate_assignment:
Expand Down
5 changes: 3 additions & 2 deletions pydantic/_internal/_model_construction.py
Expand Up @@ -9,11 +9,12 @@
from types import FunctionType
from typing import Any, Callable, Generic, Mapping

from pydantic_core import PydanticUndefined, SchemaSerializer, SchemaValidator
from pydantic_core import PydanticUndefined, SchemaSerializer
from typing_extensions import dataclass_transform, deprecated

from ..errors import PydanticUndefinedAnnotation, PydanticUserError
from ..fields import Field, FieldInfo, ModelPrivateAttr, PrivateAttr
from ..plugin._schema_validator import create_schema_validator
from ..warnings import PydanticDeprecatedSince20
from ._config import ConfigWrapper
from ._core_utils import collect_invalid_schemas, simplify_schema_references, validate_core_schema
Expand Down Expand Up @@ -495,7 +496,7 @@ def complete_model_class(

# debug(schema)
cls.__pydantic_core_schema__ = schema = validate_core_schema(schema)
cls.__pydantic_validator__ = SchemaValidator(schema, core_config)
cls.__pydantic_validator__ = create_schema_validator(schema, core_config, config_wrapper.plugin_settings)
cls.__pydantic_serializer__ = SchemaSerializer(schema, core_config)
cls.__pydantic_complete__ = True

Expand Down
3 changes: 2 additions & 1 deletion pydantic/_internal/_validate_call.py
Expand Up @@ -8,6 +8,7 @@
import pydantic_core

from ..config import ConfigDict
from ..plugin._schema_validator import create_schema_validator
from . import _discriminated_union, _generate_schema, _typing_extra
from ._config import ConfigWrapper
from ._core_utils import simplify_schema_references, validate_core_schema
Expand Down Expand Up @@ -66,7 +67,7 @@ def __init__(self, function: Callable[..., Any], config: ConfigDict | None, vali
self.__pydantic_core_schema__ = schema = schema
core_config = config_wrapper.core_config(self)
schema = _discriminated_union.apply_discriminators(schema)
self.__pydantic_validator__ = pydantic_core.SchemaValidator(schema, core_config)
self.__pydantic_validator__ = create_schema_validator(schema, core_config, config_wrapper.plugin_settings)

if self._validate_return:
return_type = (
Expand Down
6 changes: 6 additions & 0 deletions pydantic/config.py
Expand Up @@ -192,6 +192,12 @@ class without an annotation and has a type that is not in this tuple (or otherwi
[`Model.model_rebuild(_types_namespace=...)`][pydantic.BaseModel.model_rebuild]. Defaults to False.
"""

plugin_settings: dict[str, object] | None
"""A `dict` of settings for plugins. Defaults to `None`.

See [Pydantic Plugins](../usage/plugins.md) for details.
"""

schema_generator: type[_GenerateSchema] | None
"""
A custom core schema generator class to use when generating JSON schemas.
Expand Down
15 changes: 13 additions & 2 deletions pydantic/main.py
Expand Up @@ -742,7 +742,13 @@ def __getattr__(self, item: str) -> Any:
except KeyError as exc:
raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}') from exc
else:
pydantic_extra = object.__getattribute__(self, '__pydantic_extra__')
# `__pydantic_extra__` can fail to be set if the model is not yet fully initialized.
# See `BaseModel.__repr_args__` for more details
try:
lig marked this conversation as resolved.
Show resolved Hide resolved
pydantic_extra = object.__getattribute__(self, '__pydantic_extra__')
except AttributeError:
pydantic_extra = None

if pydantic_extra is not None:
try:
return pydantic_extra[item]
Expand Down Expand Up @@ -900,11 +906,16 @@ def __repr_args__(self) -> _repr.ReprArgs:
field = self.model_fields.get(k)
if field and field.repr:
yield k, v

# `__pydantic_extra__` can fail to be set if the model is not yet fully initialized.
# This can happen if a `ValidationError` is raised during initialization and the instance's
# repr is generated as part of the exception handling. Therefore, we use `getattr` here
# with a fallback, even though the type hints indicate the attribute will always be present.
pydantic_extra = getattr(self, '__pydantic_extra__', None)
try:
pydantic_extra = object.__getattribute__(self, '__pydantic_extra__')
except AttributeError:
pydantic_extra = None

if pydantic_extra is not None:
yield from ((k, v) for k, v in pydantic_extra.items())
yield from ((k, getattr(self, k)) for k, v in self.model_computed_fields.items() if v.repr)
Expand Down