From e5410de74614f5714ca3937002b52996da74434f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sun, 18 Aug 2024 19:16:26 +0200 Subject: [PATCH 1/2] fix: introduce a Extension class and fixes strange behaviors with extensions --- doc/changelog.rst | 10 +++++++ doc/tutorial.rst | 6 ++-- scim2_models/__init__.py | 4 +++ scim2_models/base.py | 2 -- scim2_models/rfc7643/enterprise_user.py | 4 +-- scim2_models/rfc7643/resource.py | 40 ++++++++++++++++++++++--- scim2_models/rfc7643/schema.py | 14 +++------ tests/test_dynamic_resources.py | 14 +++++---- tests/test_resource_extension.py | 32 ++++++++++++++++++-- 9 files changed, 97 insertions(+), 29 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index f59ef76..ecdb72e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,14 @@ Changelog ========= +[0.1.16] - Unreleased +--------------------- + +Fixed +^^^^^ + +- Fix the extension mechanism by introducing the :class:`~scim2_models.Extension` class. #60, #63 + [0.1.15] - 2024-08-18 --------------------- @@ -15,11 +23,13 @@ Fixed Changed ^^^^^^^ - Remove :class:`~scim2_models.ListResponse` ``of`` method in favor of regular type parameters. + .. note:: ``ListResponse.of(User)`` becomes ``ListResponse[User]`` and ListResponse.of(User, Group)`` becomes ``ListResponse[Union[User, Group]]``. - :data:`~scim2_models.Reference` use :data:`~typing.Literal` instead of :class:`typing.ForwardRef`. + .. note:: ``pet: Reference["Pet"]`` becomes ``pet: Reference[Literal["Pet"]]`` diff --git a/doc/tutorial.rst b/doc/tutorial.rst index d046e12..ed28fd7 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -206,7 +206,7 @@ Schema extensions ================= :rfc:`RFC7643 §3.3 <7643#section-3.3>` extensions are supported. -Extensions must be passed as resource type parameter, e.g. ``user = User[EnterpriseUser]`` or ``user = User[Union[EnterpriseUser, SuperHero]]``. +Any class inheriting from :class:`~scim2_models.Extension` can be passed as a :class:`~scim2_models.Resource` type parameter, e.g. ``user = User[EnterpriseUser]`` or ``user = User[Union[EnterpriseUser, SuperHero]]``. Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].employee_number``. .. code-block:: python @@ -271,8 +271,8 @@ Custom models ============= You can write your own model and use it the same way than the other scim2-models models. -Just inherit from :class:`~scim2_models.Resource` for your main resource, -and from :class:`~scim2_models.ComplexAttribute` for the complex attributes: +Just inherit from :class:`~scim2_models.Resource` for your main resource, or :class:`~scim2_models.Extension` for extensions. +Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes: .. code-block:: python diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index dde3bb6..e9586bc 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -14,7 +14,9 @@ from .rfc7643.enterprise_user import Manager from .rfc7643.group import Group from .rfc7643.group import GroupMember +from .rfc7643.resource import AnyExtension from .rfc7643.resource import AnyResource +from .rfc7643.resource import Extension from .rfc7643.resource import Meta from .rfc7643.resource import Resource from .rfc7643.resource_type import ResourceType @@ -53,6 +55,7 @@ __all__ = [ "Address", "AnyResource", + "AnyExtension", "Attribute", "AuthenticationScheme", "BaseModel", @@ -70,6 +73,7 @@ "Entitlement", "Error", "ExternalReference", + "Extension", "Filter", "Group", "GroupMember", diff --git a/scim2_models/base.py b/scim2_models/base.py index 6890547..b4812e3 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -745,6 +745,4 @@ def is_complex_attribute(type) -> bool: ) -AnyModel = TypeVar("AnyModel", bound=BaseModel) - BaseModelType: Type = type(BaseModel) diff --git a/scim2_models/rfc7643/enterprise_user.py b/scim2_models/rfc7643/enterprise_user.py index a879f2d..38400f6 100644 --- a/scim2_models/rfc7643/enterprise_user.py +++ b/scim2_models/rfc7643/enterprise_user.py @@ -9,7 +9,7 @@ from ..base import Mutability from ..base import Reference from ..base import Required -from .resource import Resource +from .resource import Extension class Manager(ComplexAttribute): @@ -26,7 +26,7 @@ class Manager(ComplexAttribute): """The displayName of the User's manager.""" -class EnterpriseUser(Resource): +class EnterpriseUser(Extension): schemas: List[str] = ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] employee_number: Optional[str] = None diff --git a/scim2_models/rfc7643/resource.py b/scim2_models/rfc7643/resource.py index ba28632..0810d87 100644 --- a/scim2_models/rfc7643/resource.py +++ b/scim2_models/rfc7643/resource.py @@ -15,7 +15,6 @@ from pydantic import WrapSerializer from pydantic import field_serializer -from ..base import AnyModel from ..base import BaseModel from ..base import BaseModelType from ..base import CaseExact @@ -85,6 +84,27 @@ class Meta(ComplexAttribute): """ +class Extension(BaseModel): + @classmethod + def to_schema(cls): + """Build a :class:`~scim2_models.Schema` from the current extension + class.""" + + return model_to_schema(cls) + + @classmethod + def from_schema(cls, schema) -> "Extension": + """Build a :class:`~scim2_models.Extension` subclass from the schema + definition.""" + + from .schema import make_python_model + + return make_python_model(schema, cls) + + +AnyExtension = TypeVar("AnyExtension", bound="Extension") + + def extension_serializer(value: Any, handler, info) -> Optional[Dict[str, Any]]: """Exclude the Resource attributes from the extension dump. @@ -129,7 +149,7 @@ def __new__(cls, name, bases, attrs, **kwargs): return klass -class Resource(BaseModel, Generic[AnyModel], metaclass=ResourceMetaclass): +class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass): schemas: List[str] """The "schemas" attribute is a REQUIRED attribute and is an array of Strings containing URIs that are used to indicate the namespaces of the @@ -159,13 +179,13 @@ class Resource(BaseModel, Generic[AnyModel], metaclass=ResourceMetaclass): """A complex attribute containing resource metadata.""" def __getitem__(self, item: Any): - if not isinstance(item, type) or not issubclass(item, Resource): + if not isinstance(item, type) or not issubclass(item, Extension): raise KeyError(f"{item} is not a valid extension type") return getattr(self, item.__name__) def __setitem__(self, item: Any, value: "Resource"): - if not isinstance(item, type) or not issubclass(item, Resource): + if not isinstance(item, type) or not issubclass(item, Extension): raise KeyError(f"{item} is not a valid extension type") setattr(self, item.__name__, value) @@ -232,8 +252,20 @@ def set_extension_schemas(self, schemas: List[str]): @classmethod def to_schema(cls): + """Build a :class:`~scim2_models.Schema` from the current resource + class.""" + return model_to_schema(cls) + @classmethod + def from_schema(cls, schema) -> "Resource": + """Build a :class:`scim2_models.Resource` subclass from the schema + definition.""" + + from .schema import make_python_model + + return make_python_model(schema, cls) + AnyResource = TypeVar("AnyResource", bound="Resource") diff --git a/scim2_models/rfc7643/schema.py b/scim2_models/rfc7643/schema.py index a9bbe36..a1234fd 100644 --- a/scim2_models/rfc7643/schema.py +++ b/scim2_models/rfc7643/schema.py @@ -43,11 +43,11 @@ def make_python_identifier(identifier: str) -> str: return sanitized -def make_python_model(obj: Union["Schema", "Attribute"], multiple=False) -> "Resource": +def make_python_model( + obj: Union["Schema", "Attribute"], base: Optional[Type] = None, multiple=False +) -> "Resource": """Build a Python model from a Schema or an Attribute object.""" - from scim2_models.rfc7643.resource import Resource - if isinstance(obj, Attribute): pydantic_attributes = { to_snake(make_python_identifier(attr.name)): attr.to_python() @@ -63,7 +63,6 @@ def make_python_model(obj: Union["Schema", "Attribute"], multiple=False) -> "Res if attr.name } pydantic_attributes["schemas"] = (Optional[List[str]], Field(default=[obj.id])) - base = Resource model_name = to_pascal(to_snake(obj.name)) model = create_model(model_name, __base__=base, **pydantic_attributes) @@ -210,7 +209,7 @@ def to_python(self) -> Optional[Tuple[Any, Field]]: attr_type = self.type.to_python(self.multi_valued, self.reference_types) if attr_type in (ComplexAttribute, MultiValuedComplexAttribute): - attr_type = make_python_model(self, self.multi_valued) + attr_type = make_python_model(obj=self, multiple=self.multi_valued) if self.multi_valued: attr_type = List[attr_type] # type: ignore @@ -254,8 +253,3 @@ class Schema(Resource): ] = None """A complex type that defines service provider attributes and their qualities via the following set of sub-attributes.""" - - def make_model(self) -> "Resource": - """Build a Python model from the schema definition.""" - - return make_python_model(self) diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index ac0593a..688c03a 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -12,6 +12,8 @@ from scim2_models.base import Returned from scim2_models.base import Uniqueness from scim2_models.base import URIReference +from scim2_models.rfc7643.resource import Extension +from scim2_models.rfc7643.resource import Resource from scim2_models.rfc7643.resource import is_multiple from scim2_models.rfc7643.schema import Attribute from scim2_models.rfc7643.schema import Schema @@ -20,7 +22,7 @@ def test_make_group_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.1-schema-group.json") schema = Schema.model_validate(payload) - Group = schema.make_model() + Group = Resource.from_schema(schema) assert Group.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:core:2.0:Group" @@ -148,7 +150,7 @@ def test_make_group_model_from_schema(load_sample): def test_make_user_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.1-schema-user.json") schema = Schema.model_validate(payload) - User = schema.make_model() + User = Resource.from_schema(schema) assert User.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:core:2.0:User" @@ -1257,7 +1259,7 @@ def test_make_user_model_from_schema(load_sample): def test_make_enterprise_user_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.1-schema-enterprise_user.json") schema = Schema.model_validate(payload) - EnterpriseUser = schema.make_model() + EnterpriseUser = Extension.from_schema(schema) assert EnterpriseUser.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" @@ -1446,7 +1448,7 @@ def test_make_enterprise_user_model_from_schema(load_sample): def test_make_resource_type_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.2-schema-resource_type.json") schema = Schema.model_validate(payload) - ResourceType = schema.make_model() + ResourceType = Resource.from_schema(schema) assert ResourceType.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:core:2.0:ResourceType" @@ -1639,7 +1641,7 @@ def test_make_resource_type_model_from_schema(load_sample): def test_make_service_provider_config_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.2-schema-service_provider_configuration.json") schema = Schema.model_validate(payload) - ServiceProviderConfig = schema.make_model() + ServiceProviderConfig = Resource.from_schema(schema) assert ServiceProviderConfig.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" @@ -2175,7 +2177,7 @@ def test_make_service_provider_config_model_from_schema(load_sample): def test_make_schema_model_from_schema(load_sample): payload = load_sample("rfc7643-8.7.2-schema-schema.json") schema = Schema.model_validate(payload) - Schema_ = schema.make_model() + Schema_ = Resource.from_schema(schema) assert Schema_.model_fields["schemas"].default == [ "urn:ietf:params:scim:schemas:core:2.0:Schema" diff --git a/tests/test_resource_extension.py b/tests/test_resource_extension.py index d2580e1..33373ea 100644 --- a/tests/test_resource_extension.py +++ b/tests/test_resource_extension.py @@ -7,9 +7,9 @@ from scim2_models import Context from scim2_models import EnterpriseUser +from scim2_models import Extension from scim2_models import Manager from scim2_models import Meta -from scim2_models import Resource from scim2_models import User @@ -157,6 +157,34 @@ def test_extension_no_payload(): User[EnterpriseUser].model_validate(payload) +def test_extension_validate_with_context(): + """Test the use of scim_ctx when validating resources with extensions.""" + + payload = { + "id": "3b0bc21d-1a10-4678-9e52-2f354c0c7544", + "meta": { + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "location": "https://example.com/v2/Users/3b0bc21d-1a10-4678-9e52-2f354c0c7544", + "resourceType": "User", + "version": 'W\\/"3694e05e9dff590"', + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "division": "Theme Park", + "employeeNumber": "701984", + }, + "userName": "bjensen@example.com", + } + user = User[EnterpriseUser].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + assert type(user[EnterpriseUser]) is EnterpriseUser + + def test_invalid_getitem(): """Test that an non Resource subclass __getitem__ attribute raise a KeyError.""" @@ -181,7 +209,7 @@ def test_invalid_setitem(): user[object] = "foobar" -class SuperHero(Resource): +class SuperHero(Extension): schemas: List[str] = ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] superpower: Optional[str] = None From 603a5f06e23e9df1ba527f1c022415aa2ab6fb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sun, 18 Aug 2024 19:38:34 +0200 Subject: [PATCH 2/2] feat: Enable pydantic validate_on_assigment for models --- doc/changelog.rst | 9 ++++++++- doc/tutorial.rst | 7 ++++--- scim2_models/base.py | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index ecdb72e..ff22d38 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -6,9 +6,16 @@ Changelog Fixed ^^^^^ - - Fix the extension mechanism by introducing the :class:`~scim2_models.Extension` class. #60, #63 +.. note:: + + ``schema.make_model()`` becomes ``Resource.from_schema(schema)`` or ``Extension.from_schema(schema)``. + +Changed +^^^^^^^ +- Enable pydantic :attr:`~pydantic.config.ConfigDict.validate_assignment` option. #54 + [0.1.15] - 2024-08-18 --------------------- diff --git a/doc/tutorial.rst b/doc/tutorial.rst index ed28fd7..24de2ed 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -316,7 +316,7 @@ that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7 Dynamic schemas from models =========================== -With :meth:`~scim2_models.Resource.to_schema` any model can be exported as a :class:`~scim2_models.Schema` object. +With :meth:`Resource.to_schema ` and :meth:`Extension.to_schema `, any model can be exported as a :class:`~scim2_models.Schema` object. This is useful for server implementations, so custom models or models provided by scim2-models can easily be exported on the ``/Schemas`` endpoint. @@ -353,7 +353,8 @@ This is useful for server implementations, so custom models or models provided b Dynamic models from schemas =========================== -Given a :class:`~scim2_models.Schema` object, scim2-models can dynamically generate a pythonic model to be used in your code with the :meth:`~scim2_models.Schema.make_model` method. +Given a :class:`~scim2_models.Schema` object, scim2-models can dynamically generate a pythonic model to be used in your code +with the :meth:`Resource.from_schema ` and :meth:`Extension.from_schema ` methods. .. code-block:: python :class: dropdown @@ -379,7 +380,7 @@ Given a :class:`~scim2_models.Schema` object, scim2-models can dynamically gener ], } schema = Schema.model_validate(payload) - Group = schema.make_model() + Group = Resource.from_schema(schema) my_group = Group(display_name="This is my group") This can be used by client applications that intends to dynamically discover server resources by browsing the `/Schemas` endpoint. diff --git a/scim2_models/base.py b/scim2_models/base.py index b4812e3..04fccaf 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -332,6 +332,7 @@ class BaseModel(PydanticBaseModel): validation_alias=normalize_attribute_name, serialization_alias=to_camel, ), + validate_assignment=True, populate_by_name=True, use_attribute_docstrings=True, extra="forbid",