diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a484e55..659ea30 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -11,7 +11,7 @@ v1.2.0 New features ~~~~~~~~~~~~ -- Add support for :ref:`frozen (read only) schemas `. +- Add support for frozen :ref:`schemas ` and :ref:`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. diff --git a/docs/source/guide/fields.rst b/docs/source/guide/fields.rst index 1434413..48cb17d 100644 --- a/docs/source/guide/fields.rst +++ b/docs/source/guide/fields.rst @@ -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 diff --git a/docs/source/guide/schema.rst b/docs/source/guide/schema.rst index 54f5e39..b636b23 100644 --- a/docs/source/guide/schema.rst +++ b/docs/source/guide/schema.rst @@ -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:: @@ -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-schema-passing-unknown-fields: diff --git a/docs/source/reference/exceptions.rst b/docs/source/reference/exceptions.rst index 69d869c..6c1f26d 100644 --- a/docs/source/reference/exceptions.rst +++ b/docs/source/reference/exceptions.rst @@ -9,7 +9,7 @@ Exceptions .. autoexception:: FieldNotSet :members: -.. autoexception:: SchemaFrozenError +.. autoexception:: FrozenError :members: .. autoexception:: FieldError diff --git a/oblate/exceptions.py b/oblate/exceptions.py index 8a48164..ed89236 100644 --- a/oblate/exceptions.py +++ b/oblate/exceptions.py @@ -32,7 +32,7 @@ __all__ = ( 'OblateException', 'FieldNotSet', - 'SchemaFrozenError', + 'FrozenError', 'FieldError', 'ValidationError' ) @@ -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): diff --git a/oblate/fields/base.py b/oblate/fields/base.py index aca306c..8de6396 100644 --- a/oblate/fields/base.py +++ b/oblate/fields/base.py @@ -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 @@ -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. @@ -122,6 +125,7 @@ class Field(Generic[RawValueT, FinalValueT]): __slots__ = ( 'none', 'required', + 'frozen', 'extras', '_default', '_name', @@ -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, @@ -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 @@ -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) @@ -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` diff --git a/oblate/schema.py b/oblate/schema.py index 03964f3..a541326 100644 --- a/oblate/schema.py +++ b/oblate/schema.py @@ -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 @@ -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 @@ -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) diff --git a/tests/test_configs.py b/tests/test_configs.py index 349a205..0a548fe 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -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 diff --git a/tests/test_fields_props.py b/tests/test_fields_props.py index fc001ac..9be8c56 100644 --- a/tests/test_fields_props.py +++ b/tests/test_fields_props.py @@ -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