Skip to content

Commit

Permalink
Add support for marking individual fields as frozen (read only)
Browse files Browse the repository at this point in the history
  • Loading branch information
izxxr committed Oct 21, 2023
1 parent b3cdaa9 commit 14ac3c3
Show file tree
Hide file tree
Showing 9 changed files with 77 additions and 20 deletions.
2 changes: 1 addition & 1 deletion docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ v1.2.0
New features
~~~~~~~~~~~~

- Add support for :ref:`frozen (read only) schemas <guide-schema-frozen-schemas>`.
- Add support for frozen :ref:`schemas <guide-schema-frozen-schemas>` and :ref:`fields <guide-fields-frozen-fields>`.
- Add :exc:`FieldNotSet` exception to be raised on accessing fields without values.
- Add :meth:`Schema.copy` method for copying schema instances.
- Add :meth:`Schema.preprocess_data` for preprocessing of input data.
Expand Down
23 changes: 23 additions & 0 deletions docs/source/guide/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ It is also possible to provide the set of values that correspond to True/False::
By default, ``true_values`` and ``false_values`` default to :attr:`fields.Boolean.TRUE_VALUES` and
:attr:`fields.Boolean.FALSE_VALUES` respectively.

.. _guide-fields-frozen-fields:

Frozen Fields
-------------

Frozen fields are, in other words, read only fields. These fields can only be set at initialization
time and cannot be updated.

This is done by passing ``frozen`` parameter. Whenever a field is attempted to be updated,
a :exc:`FrozenError` is raised.

Example::

class User(oblate.Schema):
id = fields.Integer(frozen=True)
username = fields.String()

user = User({'id': 1, 'username': 'test'})
user.update({'id': 2}) # FrozenError: User.id field is frozen and cannot be updated.
user.id = 1 # FrozenError: User.id field is frozen and cannot be updated.

For marking schema (all fields) as frozen, see :ref:`guide-schema-frozen-schemas`.

.. _guide-fields-extra-metadata:

Extra Metadata
Expand Down
8 changes: 5 additions & 3 deletions docs/source/guide/schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Frozen schemas are schemas whose fields cannot be updated once initialized. In o
words, these schemas are marked as read only.

In order to mark a schema as "frozen", the :attr:`SchemaConfig.frozen` attribute is set
to ``True``. Whenever a field is attempted to be updated, a :exc:`SchemaFrozenError` is raised.
to ``True``. Whenever a field is attempted to be updated, a :exc:`FrozenError` is raised.

Example::

Expand All @@ -252,8 +252,10 @@ Example::
frozen = True

user = User({'id': 1, 'username': 'test'})
user.update({'id': 2}) # SchemaFrozenError: User schema is frozen and cannot be updated.
user.id = 1 # SchemaFrozenError: User schema is frozen and cannot be updated.
user.update({'id': 2}) # FrozenError: User schema is frozen and cannot be updated.
user.id = 1 # FrozenError: User schema is frozen and cannot be updated.

Individual fields can be marked as frozen too :ref:`in a similar fashion <guide-fields-frozen-fields>`.

.. _guide-schema-passing-unknown-fields:

Expand Down
2 changes: 1 addition & 1 deletion docs/source/reference/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Exceptions
.. autoexception:: FieldNotSet
:members:

.. autoexception:: SchemaFrozenError
.. autoexception:: FrozenError
:members:

.. autoexception:: FieldError
Expand Down
22 changes: 14 additions & 8 deletions oblate/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
__all__ = (
'OblateException',
'FieldNotSet',
'SchemaFrozenError',
'FrozenError',
'FieldError',
'ValidationError'
)
Expand Down Expand Up @@ -72,17 +72,23 @@ def __init__(self, field: Field[Any, Any], schema: Schema, field_name: str) -> N
super().__init__(f'Field {field._name!r} has no value set', field._name, schema)


class SchemaFrozenError(OblateException):
"""An exception raised when update is performed on a frozen schema.
class FrozenError(OblateException):
"""An exception raised when a frozen field or schema is updated.
Attributes
----------
schema: :class:`Schema`
The schema that was attempted to be updated.
entity: Union[:class:`Schema`, :class:`Field`]
The schema or field that was attempted to be updated.
"""
def __init__(self, schema: Schema) -> None:
self.schema = schema
super().__init__(f'{schema.__class__.__name__} schema is frozen and cannot be updated')
def __init__(self, entity: Union[Schema, Field[Any, Any]]) -> None:
from oblate.schema import Schema # circular import

self.entity = entity

name = f'{entity.__class__.__name__} schema' if isinstance(entity, Schema) else \
f'{entity._schema.__name__}.{entity._name} field'

super().__init__(f'{name} is frozen and cannot be updated')


class FieldError(OblateException):
Expand Down
14 changes: 11 additions & 3 deletions oblate/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from oblate.schema import Schema
from oblate.validate import Validator, ValidatorCallbackT, InputT
from oblate.utils import MISSING, current_field_key, current_schema
from oblate.exceptions import FieldError, SchemaFrozenError
from oblate.exceptions import FieldError, FrozenError
from oblate.contexts import ErrorContext
from oblate.configs import config

Expand Down Expand Up @@ -92,6 +92,9 @@ class Field(Generic[RawValueT, FinalValueT]):
Whether this field allows None values to be set.
required: :class:`bool`
Whether this field is required.
frozen: :class:`bool`
Whether the field is frozen. Frozen fields are read only fields
that cannot be updated once initialized.
default:
The default value for this field. If this is passed, the field is automatically
marked as optional i.e ``required`` parameter gets ignored.
Expand Down Expand Up @@ -122,6 +125,7 @@ class Field(Generic[RawValueT, FinalValueT]):
__slots__ = (
'none',
'required',
'frozen',
'extras',
'_default',
'_name',
Expand All @@ -137,6 +141,7 @@ def __init__(
*,
none: bool = False,
required: bool = True,
frozen: bool = False,
default: Any = MISSING,
validators: Sequence[ValidatorT[Any, Any]] = MISSING,
extras: Dict[str, Any] = MISSING,
Expand All @@ -146,6 +151,7 @@ def __init__(
) -> None:

self.none = none
self.frozen = frozen
self.required = required and (default is MISSING)
self.extras = extras if extras is not MISSING else {}
self._load_key = data_key if data_key is not MISSING else load_key
Expand Down Expand Up @@ -175,7 +181,9 @@ def __get__(self, instance: Optional[Schema], owner: Type[Schema]) -> Union[Fina

def __set__(self, instance: Schema, value: RawValueT) -> None:
if instance.__config__.frozen:
raise SchemaFrozenError(instance)
raise FrozenError(instance)
if self.frozen:
raise FrozenError(self)

schema_token = current_schema.set(instance)
field_name = current_field_key.set(self._name)
Expand Down Expand Up @@ -270,7 +278,7 @@ def default(self) -> Any:
@property
def schema(self) -> Type[Schema]:
"""The schema that the field belongs to.
.. versionadded:: 1.1
:type: :class:`Schema`
Expand Down
9 changes: 7 additions & 2 deletions oblate/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from typing_extensions import Self
from oblate.contexts import SchemaContext, LoadContext, DumpContext
from oblate.utils import MISSING, current_field_key, current_context, current_schema
from oblate.exceptions import FieldError, FieldNotSet, SchemaFrozenError
from oblate.exceptions import FieldError, FieldNotSet, FrozenError
from oblate.configs import config, SchemaConfig

import collections.abc
Expand Down Expand Up @@ -400,11 +400,14 @@ def update(self, data: Mapping[str, Any], /, *, ignore_extra: bool = MISSING) ->
Raises
------
FrozenError
The schema is read only or one of the fields attempted to
be updated is read only and cannot be updated.
ValidationError
The validation failed.
"""
if self.__config__.frozen:
raise SchemaFrozenError(self)
raise FrozenError(self)

if ignore_extra is MISSING:
ignore_extra = self.__config__.ignore_extra
Expand All @@ -421,6 +424,8 @@ def update(self, data: Mapping[str, Any], /, *, ignore_extra: bool = MISSING) ->
if not ignore_extra:
errors.append(FieldError(f'Invalid or unknown field.'))
else:
if field.frozen:
raise FrozenError(field)
errors.extend(self._process_field_value(field, value))
finally:
current_field_key.reset(token)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ class Config(oblate.SchemaConfig):

schema = _SchemaFrozen({'id': 1})

with pytest.raises(oblate.SchemaFrozenError, match='_SchemaFrozen schema is frozen and cannot be updated'):
with pytest.raises(oblate.FrozenError, match='_SchemaFrozen schema is frozen and cannot be updated'):
schema.update({'id': 2})

with pytest.raises(oblate.SchemaFrozenError, match='_SchemaFrozen schema is frozen and cannot be updated'):
with pytest.raises(oblate.FrozenError, match='_SchemaFrozen schema is frozen and cannot be updated'):
schema.id = 3
13 changes: 13 additions & 0 deletions tests/test_fields_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,16 @@ class _TestSchema(oblate.Schema):

assert schema.get_value_for('Id') == 20
assert schema.get_value_for('id') == 20

def test_field_frozen():
class _TestSchema(oblate.Schema):
id = fields.Integer(frozen=True)
name = fields.String()

schema = _TestSchema({'id': 20, 'name': 'John'})

with pytest.raises(oblate.FrozenError, match='_TestSchema.id field is frozen and cannot be updated'):
schema.update({'id': 2})

with pytest.raises(oblate.FrozenError, match='_TestSchema.id field is frozen and cannot be updated'):
schema.id = 3

0 comments on commit 14ac3c3

Please sign in to comment.