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
14 changes: 12 additions & 2 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ Changelog
[0.x.x] - Unreleased
--------------------

Changed
Added
^^^^^
- Introduce a Path object to handle paths. :issue:`111`
- Resources define their schema URN with a ``__schema__`` classvar instead of a ``schemas`` default value. :issue:`110`
- Validation that the base schema is present in ``schemas`` during SCIM context validation.
- Validation that extension schemas are known during SCIM context validation.

Changed
^^^^^^^
- Introduce a :class:`~scim2_models.Path` object to handle paths. :issue:`111`

Deprecated
^^^^^^^^^^
- Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead.

[0.5.2] - 2026-01-22
--------------------
Expand Down
12 changes: 7 additions & 5 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes

.. code-block:: python

>>> from typing import Annotated, Optional, List
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute
>>> from typing import Annotated, Optional
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute, URN
>>> from enum import Enum

>>> class PetType(ComplexAttribute):
Expand All @@ -317,7 +317,7 @@ Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes
... """The pet color."""

>>> class Pet(Resource):
... schemas: List[str] = ["example:schemas:Pet"]
... __schema__ = URN("urn:example:schemas:Pet")
...
... name: Annotated[Optional[str], Mutability.immutable, Returned.always]
... """The name of the pet."""
Expand Down Expand Up @@ -351,18 +351,20 @@ This is useful for server implementations, so custom models or models provided b

.. code-block:: python

>>> from scim2_models import Resource, URN

>>> class MyCustomResource(Resource):
... """My awesome custom schema."""
...
... schemas: List[str] = ["example:schemas:MyCustomResource"]
... __schema__ = URN("urn:example:schemas:MyCustomResource")
...
... foobar: Optional[str]
...
>>> schema = MyCustomResource.to_schema()
>>> dump = schema.model_dump()
>>> assert dump == {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
... "id": "example:schemas:MyCustomResource",
... "id": "urn:example:schemas:MyCustomResource",
... "name": "MyCustomResource",
... "description": "My awesome custom schema.",
... "attributes": [
Expand Down
17 changes: 9 additions & 8 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,9 @@ def _set_complex_attribute_urns(self) -> None:
main_schema = self._attribute_urn
separator = "."
else:
main_schema = self.__class__.model_fields["schemas"].default[0]
main_schema = getattr(self.__class__, "__schema__", None)
if main_schema is None:
return
separator = ":"

for field_name in self.__class__.model_fields:
Expand Down Expand Up @@ -540,13 +542,12 @@ def get_attribute_urn(self, field_name: str) -> str:
"""
from scim2_models.resources.resource import Extension

main_schema = self.__class__.model_fields["schemas"].default[0]
main_schema = getattr(self.__class__, "__schema__", None)
field = self.__class__.model_fields[field_name]
alias = field.serialization_alias or field_name
field_type = self.get_field_root_type(field_name)
full_urn = (
alias
if isclass(field_type) and issubclass(field_type, Extension)
else f"{main_schema}:{alias}"
)
return full_urn
if isclass(field_type) and issubclass(field_type, Extension):
return alias
if main_schema is None:
return alias
return f"{main_schema}:{alias}"
10 changes: 3 additions & 7 deletions scim2_models/messages/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from pydantic import Field
from pydantic import PlainSerializer

from ..annotations import Required
from ..attributes import ComplexAttribute
from ..path import URN
from ..utils import _int_to_str
from .message import Message

Expand Down Expand Up @@ -53,9 +53,7 @@ class BulkRequest(Message):
The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
"""

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:BulkRequest"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest")

fail_on_errors: int | None = None
"""An integer specifying the number of errors that the service provider
Expand All @@ -76,9 +74,7 @@ class BulkResponse(Message):
The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
"""

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:BulkResponse"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse")

operations: list[BulkOperation] | None = Field(
None, serialization_alias="Operations"
Expand Down
6 changes: 2 additions & 4 deletions scim2_models/messages/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@

from pydantic import PlainSerializer

from ..annotations import Required
from ..path import URN
from ..utils import _int_to_str
from .message import Message


class Error(Message):
"""Representation of SCIM API errors."""

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:Error"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:Error")

status: Annotated[int | None, PlainSerializer(_int_to_str)] = None
"""The HTTP status code (see Section 6 of [RFC7231]) expressed as a JSON
Expand Down
7 changes: 2 additions & 5 deletions scim2_models/messages/list_response.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from typing import Annotated
from typing import Any
from typing import Generic

Expand All @@ -9,17 +8,15 @@
from pydantic_core import PydanticCustomError
from typing_extensions import Self

from ..annotations import Required
from ..context import Context
from ..path import URN
from ..resources.resource import AnyResource
from .message import Message
from .message import _GenericMessageMetaclass


class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetaclass):
schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:ListResponse")

total_results: int | None = None
"""The total number of results returned by the list or query operation."""
Expand Down
8 changes: 4 additions & 4 deletions scim2_models/messages/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

from pydantic import Discriminator
from pydantic import Tag
from pydantic._internal._model_construction import ModelMetaclass

from scim2_models.resources.resource import Resource

from ..base import BaseModel
from ..scim_object import ScimMetaclass
from ..scim_object import ScimObject
from ..utils import UNION_TYPES

Expand Down Expand Up @@ -56,7 +56,7 @@ def _get_tag(resource_type: type[BaseModel]) -> Tag:
:param resource_type: SCIM resource type
:return: Pydantic Tag for discrimination
"""
return Tag(resource_type.model_fields["schemas"].default[0])
return Tag(getattr(resource_type, "__schema__", None) or "")


def _create_tagged_resource_union(resource_union: Any) -> Any:
Expand All @@ -75,7 +75,7 @@ def _create_tagged_resource_union(resource_union: Any) -> Any:

# Set up schemas for the discriminator function
resource_types_schemas = [
resource_type.model_fields["schemas"].default[0]
getattr(resource_type, "__schema__", None) or ""
for resource_type in resource_types
]

Expand All @@ -92,7 +92,7 @@ def _create_tagged_resource_union(resource_union: Any) -> Any:
return Annotated[union, discriminator]


class _GenericMessageMetaclass(ModelMetaclass):
class _GenericMessageMetaclass(ScimMetaclass):
"""Metaclass for SCIM generic types with discriminated unions."""

def __new__(
Expand Down
5 changes: 2 additions & 3 deletions scim2_models/messages/patch_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..annotations import Required
from ..attributes import ComplexAttribute
from ..context import Context
from ..path import URN
from ..path import InvalidPathError
from ..path import Path
from ..path import PathNotFoundError
Expand Down Expand Up @@ -199,9 +200,7 @@ def __class_getitem__(

return super().__class_getitem__(typevar_values)

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:PatchOp")

operations: Annotated[list[PatchOperation[ResourceT]] | None, Required.true] = (
Field(None, serialization_alias="Operations", min_length=1)
Expand Down
7 changes: 2 additions & 5 deletions scim2_models/messages/search_request.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from enum import Enum
from typing import Annotated
from typing import Any

from pydantic import field_validator
from pydantic import model_validator

from ..annotations import Required
from ..path import URN
from ..path import Path
from .message import Message

Expand All @@ -16,9 +15,7 @@ class SearchRequest(Message):
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
"""

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:SearchRequest"
]
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest")

attributes: list[Path[Any]] | None = None
"""A multi-valued list of strings indicating the names of resource
Expand Down
26 changes: 14 additions & 12 deletions scim2_models/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ class _Resolution(NamedTuple):
is_explicit_schema_path: bool = False


class URN(UserString):
class URN(str):
"""URN string type with validation."""

def __new__(cls, urn: str) -> "URN":
cls.check_syntax(urn)
return super().__new__(cls, urn)

@classmethod
def __get_pydantic_core_schema__(
cls,
Expand All @@ -83,10 +89,6 @@ def __get_pydantic_core_schema__(
),
)

def __init__(self, urn: str):
self.check_syntax(urn)
self.data = urn

@classmethod
def check_syntax(cls, path: str) -> None:
"""Validate URN-based path format.
Expand Down Expand Up @@ -330,7 +332,7 @@ def urn(self) -> str | None:

schema = self.schema
if not schema and issubclass(self.__scim_model__, Resource):
schema = self.__scim_model__.model_fields["schemas"].default[0]
schema = self.__scim_model__.__schema__

if not self.attr:
return schema if schema else None
Expand All @@ -348,13 +350,12 @@ def _resolve_model(self) -> tuple[type[BaseModel], str | None] | None:
attr_path = self.attr

if ":" in self and isclass(model) and issubclass(model, Resource | Extension):
model_schema = model.model_fields["schemas"].default[0]
path_lower = str(self).lower()

if path_lower == model_schema.lower():
if model.__schema__ and path_lower == model.__schema__.lower():
return model, None
elif path_lower.startswith(model_schema.lower()):
attr_path = str(self)[len(model_schema) :].lstrip(":")
elif model.__schema__ and path_lower.startswith(model.__schema__.lower()):
attr_path = str(self)[len(model.__schema__) :].lstrip(":")
elif issubclass(model, Resource):
for (
extension_schema,
Expand Down Expand Up @@ -414,7 +415,7 @@ def _resolve_instance(
if ":" not in path_str:
return _Resolution(resource, path_str)

model_schema = type(resource).model_fields["schemas"].default[0]
model_schema = getattr(type(resource), "__schema__", "") or ""
path_lower = path_str.lower()

if isinstance(resource, Resource | Extension) and path_lower.startswith(
Expand Down Expand Up @@ -708,10 +709,11 @@ def iter_model_paths(

field_type = target_model.get_field_root_type(field_name)

urn: str
if isclass(field_type) and issubclass(field_type, Extension):
if not include_extensions:
continue
urn = field_type.model_fields["schemas"].default[0]
urn = field_type.__schema__ or ""
elif isclass(target_model) and issubclass(target_model, Extension):
urn = target_model().get_attribute_urn(field_name)
else:
Expand Down
5 changes: 2 additions & 3 deletions scim2_models/resources/enterprise_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ..annotations import Mutability
from ..annotations import Required
from ..attributes import ComplexAttribute
from ..path import URN
from ..reference import Reference
from .resource import Extension

Expand All @@ -25,9 +26,7 @@ class Manager(ComplexAttribute):


class EnterpriseUser(Extension):
schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
]
__schema__ = URN("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")

employee_number: str | None = None
"""Numeric or alphanumeric identifier assigned to a person, typically based
Expand Down
6 changes: 2 additions & 4 deletions scim2_models/resources/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from pydantic import Field

from ..annotations import Mutability
from ..annotations import Required
from ..attributes import ComplexAttribute
from ..path import URN
from ..reference import Reference
from .resource import Resource

Expand All @@ -32,9 +32,7 @@ class GroupMember(ComplexAttribute):


class Group(Resource[Any]):
schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:schemas:core:2.0:Group"
]
__schema__ = URN("urn:ietf:params:scim:schemas:core:2.0:Group")

display_name: str | None = None
"""A human-readable name for the Group."""
Expand Down
Loading
Loading