Skip to content

Commit

Permalink
Merge pull request #69 from yaal-coop/issue-63-extension-self
Browse files Browse the repository at this point in the history
Rework the extension mechanism
  • Loading branch information
azmeuk authored Aug 18, 2024
2 parents aeacc71 + 603a5f0 commit 4a02e77
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 32 deletions.
17 changes: 17 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Changelog
=========

[0.1.16] - Unreleased
---------------------

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
---------------------

Expand All @@ -15,11 +30,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"]]``
Expand Down
13 changes: 7 additions & 6 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <scim2_models.Resource.to_schema>` and :meth:`Extension.to_schema <scim2_models.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.


Expand Down Expand Up @@ -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 <scim2_models.Resource.from_schema>` and :meth:`Extension.from_schema <scim2_models.Extension.from_schema>` methods.

.. code-block:: python
:class: dropdown
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,6 +55,7 @@
__all__ = [
"Address",
"AnyResource",
"AnyExtension",
"Attribute",
"AuthenticationScheme",
"BaseModel",
Expand All @@ -70,6 +73,7 @@
"Entitlement",
"Error",
"ExternalReference",
"Extension",
"Filter",
"Group",
"GroupMember",
Expand Down
3 changes: 1 addition & 2 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -745,6 +746,4 @@ def is_complex_attribute(type) -> bool:
)


AnyModel = TypeVar("AnyModel", bound=BaseModel)

BaseModelType: Type = type(BaseModel)
4 changes: 2 additions & 2 deletions scim2_models/rfc7643/enterprise_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
40 changes: 36 additions & 4 deletions scim2_models/rfc7643/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
14 changes: 4 additions & 10 deletions scim2_models/rfc7643/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
14 changes: 8 additions & 6 deletions tests/test_dynamic_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
32 changes: 30 additions & 2 deletions tests/test_resource_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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": "[email protected]",
}
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."""
Expand All @@ -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
Expand Down

0 comments on commit 4a02e77

Please sign in to comment.