Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pydantic
import pytest

import pydantic_scim2


@pytest.fixture(autouse=True)
def add_doctest_namespace(doctest_namespace):
doctest_namespace["pydantic"] = pydantic
imports = {item: getattr(pydantic_scim2, item) for item in pydantic_scim2.__all__}
doctest_namespace.update(imports)
return doctest_namespace
6 changes: 6 additions & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@
autodoc_pydantic_model_show_config_summary = False
autodoc_pydantic_model_show_field_summary = False
autodoc_pydantic_model_show_json = False

# -- Options for doctest -------------------------------------------

doctest_global_setup = """
from pydantic_scim2 import *
"""
118 changes: 99 additions & 19 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ Tutorial
Model parsing
=============

Pydantic :func:`~pydantic.BaseModel.model_validate` method can be used to parse and validate SCIM2 payloads.
Pydantic :func:`~pydantic_scim2.BaseModel.model_validate` method can be used to parse and validate SCIM2 payloads.
Python models have generally the same name than in the SCIM specifications, they are simply snake cased.


.. doctest::
.. code-block:: python
:emphasize-lines: 17

>>> from pydantic_scim2 import User
>>> import datetime
Expand Down Expand Up @@ -36,9 +37,10 @@ Python models have generally the same name than in the SCIM specifications, they
Model serialization
===================

Pydantic :func:`~pydantic.BaseModel.model_dump` method can be used to produce valid SCIM2 payloads:
Pydantic :func:`~pydantic_scim2.BaseModel.model_dump` method have been tuned to produce valid SCIM2 payloads.

.. doctest::
.. code-block:: python
:emphasize-lines: 16

>>> from pydantic_scim2 import User, Meta
>>> import datetime
Expand All @@ -55,7 +57,7 @@ Pydantic :func:`~pydantic.BaseModel.model_dump` method can be used to produce va
... ),
... )

>>> dump = user.model_dump(exclude_none=True, by_alias=True, mode="json")
>>> dump = user.model_dump()
>>> assert dump == {
... "schemas": [
... "urn:ietf:params:scim:schemas:core:2.0:User"
Expand All @@ -71,14 +73,78 @@ Pydantic :func:`~pydantic.BaseModel.model_dump` method can be used to produce va
... "userName": "bjensen@example.com"
... }

Contexts
========

The SCIM specifications detail some :class:`~pydantic_scim2.Mutability` and :class:`~pydantic_scim2.Returned` parameters for model attributes.
Depending on the context, they will indicate that attributes should be present, absent, be ignored.

For instance, attributes marked as :attr:`~pydantic_scim2.Mutability.read_only` should not be sent by SCIM clients on resource creation requests.
By passing the right :class:`~pydantic_scim2.Context` to the :meth:`~pydantic_scim2.BaseModel.model_dump` method, only the expected fields will be dumped for this context:

.. code-block:: python
:caption: Client generating a resource creation request payload

>>> from pydantic_scim2 import User, Context
>>> user = User(user_name="bjensen@example.com")
>>> payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)

In the same fashion, by passing the right :class:`~pydantic_scim2.Context` to the :meth:`~pydantic_scim2.BaseModel.model_validate` method,
fields with unexpected values will raise :class:`~pydantic.ValidationError`:

.. code-block:: python
:caption: Server validating a resource creation request payload

>>> from pydantic_scim2 import User, Context
>>> from pydantic import ValidationError
>>> try:
... obj = User.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
... except pydantic.ValidationError:
... obj = Error(...)

Attributes inclusions and exclusions
====================================

In some situations it might be needed to exclude, or only include a given set of attributes when serializing a model.
This happens for instance when servers build response payloads for clients requesting only a sub-set the model attributes.
As defined in :rfc:`RFC7644 §3.9 <7644#section-3.9>`, :code:`attributes` and :code:`excluded_attributes` parameters can
be passed to :meth:`~pydantic_scim2.BaseModel.model_dump`.
The expected attribute notation is the one detailed on :rfc:`RFC7644 §3.10 <7644#section-3.10>`,
like :code:`urn:ietf:params:scim:schemas:core:2.0:User:userName`, or :code:`userName` for short.

.. code-block:: python
:emphasize-lines: 5

>>> from pydantic_scim2 import User, Context
>>> user = User(user_name="bjensen@example.com", display_name="bjensen")
>>> payload = user.model_dump(
... scim_ctx=Context.RESOURCE_QUERY_REQUEST,
... excluded_attributes=["displayName"]
... )
>>> assert payload == {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "userName": "bjensen@example.com",
... "displayName": "bjensen",
... }

Values read from :attr:`~pydantic_scim2.SearchRequest.attributes` and :attr:`~pydantic_scim2.SearchRequest.excluded_attributes` in :class:`~pydantic_scim2.SearchRequest` objects can directly be used in :meth:`~pydantic_scim2.BaseModel.model_dump`.

Attribute inclusions and exclusions interact with attributes :class:`~pydantic_scim2.Returned`, in the server response :class:`Contexts <pydantic_scim2.Context>`:

- attributes annotated with :attr:`~pydantic_scim2.Returned.always` will always be dumped;
- attributes annotated with :attr:`~pydantic_scim2.Returned.never` will never be dumped;
- attributes annotated with :attr:`~pydantic_scim2.Returned.default` will be dumped unless being explicitly excluded;
- attributes annotated with :attr:`~pydantic_scim2.Returned.request` will be not dumped unless being explicitly included.

Typed ListResponse
==================

:class:`~pydantic_scim2.ListResponse` models take a type or a :data:`~typing.Union` of types.
You must pass the type you expect in the response, e.g. :class:`~pydantic_scim2.ListResponse.of(User)` or :class:`~pydantic_scim2.ListResponse.of(User, Group)`.
If a response resource type cannot be found, a ``pydantic.ValidationError`` will be raised.

.. doctest::
.. code-block:: python
:emphasize-lines: 49

>>> from typing import Union
>>> from pydantic_scim2 import User, Group, ListResponse
Expand Down Expand Up @@ -143,7 +209,7 @@ Schema extensions
Extensions must be passed as resource type parameter, e.g. ``user = User[EnterpriseUser]`` or ``user = User[EnterpriseUser, SuperHero]``.
Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].employee_number``.

.. doctest::
.. code-block:: python

>>> import datetime
>>> from pydantic_scim2 import User, EnterpriseUser, Meta
Expand All @@ -161,7 +227,7 @@ Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].em

>>> user[EnterpriseUser] = EnterpriseUser(employee_number = "701984")
>>> user[EnterpriseUser].division="Theme Park"
>>> dump = user.model_dump(exclude_none=True, by_alias=True, mode="json")
>>> dump = user.model_dump()
>>> assert dump == {
... "schemas": [
... "urn:ietf:params:scim:schemas:core:2.0:User",
Expand All @@ -188,11 +254,11 @@ Pre-defined Error objects

:rfc:`RFC7643 §3.12 <7643#section-3.12>` pre-defined errors are usable.

.. doctest::
.. code-block:: python

>>> from pydantic_scim2 import InvalidPathError

>>> dump = InvalidPathError.model_dump(exclude_none=True, by_alias=True, mode="json")
>>> dump = InvalidPathError.model_dump()
>>> assert dump == {
... 'detail': 'The "path" attribute was invalid or malformed (see Figure 7 of RFC7644).',
... 'schemas': ['urn:ietf:params:scim:api:messages:2.0:Error'],
Expand All @@ -206,20 +272,34 @@ The exhaustive list is availaible in the :class:`reference <pydantic_scim2.Error
Custom models
=============

You can write your own model and use it the same way than the other pydantic-scim2 models. Just inherit from :class:`~pydantic_scim2.Resource`:
You can write your own model and use it the same way than the other pydantic-scim2 models.
Just inherit from :class:`~pydantic_scim2.Resource` for your main resource,
and from :class:`~pydantic_scim2.ComplexAttribute` for the complex attributes:

.. doctest::
.. code-block:: python

>>> from pydantic_scim2 import Resource
>>> from typing import Annotated, Optional
>>> from pydantic_scim2 import Resource, Returned, Mutability, ComplexAttribute
>>> from enum import Enum

>>> class Pet(Resource):
... class Type(str, Enum):
... dog = "dog"
... cat = "cat"
>>> class PetType(ComplexAttribute):
... type: Optional[str]
... """The pet type like 'cat' or 'dog'."""
...
... name : str
... color: Optional[str]
... """The pet color."""

>>> class Pet(Resource):
... name : Annotated[Optional[str], Mutability.immutable, Returned.always]
... """The name of the pet."""
...
... type: Type
... pet_type: Optional[PetType]
... """The pet type."""

You can annotate fields to indicate their :class:`~pydantic_scim2.Mutability` and :class:`~pydantic_scim2.Returned`.
If unset the default values will be :attr:`~pydantic_scim2.Mutability.read_write` and :attr:`~pydantic_scim2.Returned.default`.

.. warning::

Be sure to make all the fields of your model :data:`~typing.Optional`.
There will always be a :class:`~pydantic_scim2.Context` in which this will be true.
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions pydantic_scim2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
from .base import ComplexAttribute
from .base import Context
from .base import Mutability
from .base import Required
from .base import Returned
from .base import Uniqueness
from .rfc7643.enterprise_user import EnterpriseUser
from .rfc7643.enterprise_user import Manager
from .rfc7643.group import Group
Expand All @@ -8,10 +14,7 @@
from .rfc7643.resource_type import ResourceType
from .rfc7643.resource_type import SchemaExtension
from .rfc7643.schema import Attribute
from .rfc7643.schema import Mutability
from .rfc7643.schema import Returned
from .rfc7643.schema import Schema
from .rfc7643.schema import Uniqueness
from .rfc7643.service_provider import AuthenticationScheme
from .rfc7643.service_provider import Bulk
from .rfc7643.service_provider import ChangePassword
Expand Down Expand Up @@ -61,6 +64,8 @@
"BulkRequest",
"BulkResponse",
"ChangePassword",
"ComplexAttribute",
"Context",
"ETag",
"Email",
"EnterpriseUser",
Expand Down Expand Up @@ -88,6 +93,7 @@
"PatchOperation",
"PhoneNumber",
"Photo",
"Required",
"Resource",
"ResourceType",
"Returned",
Expand Down
89 changes: 89 additions & 0 deletions pydantic_scim2/attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type


def validate_model_attribute(model: Type, attribute_base: str) -> None:
"""Validate that an attribute name or a sub-attribute path exist for a
given model."""

from pydantic_scim2.base import BaseModel

attribute_name, *sub_attribute_blocks = attribute_base.split(".")
sub_attribute_base = ".".join(sub_attribute_blocks)

aliases = {field.alias for field in model.model_fields.values()}

if attribute_name not in aliases:
raise ValueError(
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
)

if sub_attribute_base:
attribute_type = model.get_field_root_type(attribute_name)

if not issubclass(attribute_type, BaseModel):
raise ValueError(
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
)

validate_model_attribute(attribute_type, sub_attribute_base)


def extract_schema_and_attribut_base(attribute_urn: str) -> Tuple[str, str]:
"""Extract the schema urn part and the attribute name part from attribute
name, as defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`."""

*urn_blocks, attribute_base = attribute_urn.split(":")
schema = ":".join(urn_blocks)
return schema, attribute_base


def validate_attribute_urn(
attribute_name: str,
default_resource: Optional[Type] = None,
resource_types: Optional[List[Type]] = None,
) -> str:
"""Validate that an attribute urn is valid or not.

:parm attribute_name: The attribute urn to check.
:default_resource: The default resource if `attribute_name` is not an absolute urn.
:resource_types: The available resources in which to look for the attribute.
:return: The normalized attribute URN.
"""

from pydantic_scim2.rfc7643.resource import Resource

if not resource_types:
resource_types = []

if default_resource and default_resource not in resource_types:
resource_types.append(default_resource)

default_schema = (
default_resource.model_fields["schemas"].default[0]
if default_resource
else None
)

schema, attribute_base = extract_schema_and_attribut_base(attribute_name)
if not schema:
schema = default_schema

if not schema:
raise ValueError("No default schema and relative URN")

resource = Resource.get_by_schema(resource_types, schema)
if not resource:
raise ValueError(f"No resource matching schema '{schema}'")

validate_model_attribute(resource, attribute_base)

return f"{schema}:{attribute_base}"


def contains_attribute_or_subattributes(attribute_urns: List[str], attribute_urn):
return attribute_urn in attribute_urns or any(
item.startswith(f"{attribute_urn}.") for item in attribute_urns
)
Loading