Source code for nbtparse.semantics.nbtobject

"""NBT high-level object-oriented class

:class:`NBTObject` is an object-oriented wrapper for
:class:`~.tags.CompoundTag`, designed to expose its fields in a standard and
well-documented fashion.

"""
from collections import abc as cabc
import io
import logging
import reprlib
import warnings

from ..syntax import tags
from .. import exceptions
from . import fields


logger = logging.getLogger(__name__)


class _FieldShim:
    def __init__(self, name, wrapped) -> None:
        self.wrapped = wrapped
        self.name = name

    def __repr__(self):
        return '_FieldShim({!r}, {!r})'.format(self.name, self.wrapped)

    def __get__(self, obj: 'NBTObject', owner: 'NBTMeta') -> object:
        if obj is None:
            return self.wrapped
        return self.wrapped.get_field_value(obj, self.name)

    def __set__(self, obj: 'NBTObject', value: object) -> None:
        self.wrapped.set_field_value(obj, self.name, value)

    def __delete__(self, obj: 'NBTObject') -> None:
        self.wrapped.delete_field_value(obj, self.name)

    def __getattr__(self, name) -> object:
        return getattr(self.wrapped, name)


[docs]class NBTMeta(type): """Metaclass for NBTObjects. Allows NBTObjects to track their fields upon creation. """ def __new__(mcs, name, bases, dct, **kwargs): original_dct = dct dct = {key: mcs.__wrap_field(key, value) for key, value in dct.items()} cls = super().__new__(mcs, name, bases, dct, **kwargs) cls.__used = False # True once someone has used the class in a way # that makes it unsafe to modify in-place. class_fields = set() for name, value in original_dct.items(): if isinstance(value, fields.AbstractField): value.attach(cls) class_fields.add(name) cls.__fields = class_fields return cls @staticmethod def __wrap_field(name, field): if isinstance(field, fields.AbstractField): return _FieldShim(name, field) elif isinstance(field, _FieldShim): return _FieldShim(name, field.wrapped) return field def __call__(cls, *args, **kwargs): result = super().__call__(*args, **kwargs) cls.__used = True return result
[docs] def fields(cls) -> cabc.Set: """Return a set of the public fields attached to this class. The elements of the set are strings whose names correspond to those of the fields. They are suitable arguments for :func:`getattr` and :func:`setattr`. Does not include fields whose names begin with underscores, but is otherwise equivalent to :meth:`all_fields`. """ return set(key for key in cls.all_fields() if not key.startswith('_'))
[docs] def all_fields(cls) -> cabc.Set: """Return a set of all fields attached to this class. Includes fields whose names begin with underscores. .. note:: Fields defined in classes which do not derive from :class:`NBTObject` will not be included. This is of no concern to most developers, but may cause problems if you plan on using multiple inheritance to provide the same fields to several different classes. To avoid this issue, always derive your mixin classes from NBTObject. """ result = set() mro = cls.__mro__ for base in mro: if isinstance(base, NBTMeta): result |= base.__fields return result
def __setattr__(cls, name: str, new_value: object): """Tracks changes to the fields attached to this class.""" if name.startswith('_NBTMeta__'): super().__setattr__(name, new_value) return if cls.__used: raise AttributeError('Modifying a class after it has been ' 'instantiated or derived from is not safe.') if isinstance(new_value, fields.AbstractField): cls.__fields.add(name) else: cls.__fields.discard(name) wrapped_value = cls.__wrap_field(name, new_value) super().__setattr__(name, wrapped_value) def __delattr__(cls, name: str): """Tracks changes to the fields attached to a class.""" if cls.__used: raise AttributeError('Modifying a class after it has been ' 'instantiated or derived from is not safe.') cls.__fields.discard(name) super().__delattr__(name)
[docs]class NBTObject(metaclass=NBTMeta): """Thin wrapper over a ``TAG_Compound``. Typically houses one or more :mod:`fields<nbtparse.semantics.fields>`. Calling the constructor directly will create an empty object with all fields set to their default values, except for any specifically initialized as keyword arguments. Calling :meth:`from_nbt` will populate fields from the provided NBT. .. note:: If the default of a field is :obj:`None`, that field will be set to :obj:`None`, which is equivalent to leaving it empty. Such fields will not be present in the generated NBT unless their values are changed by hand. In some cases, this means newly created objects will require modification before they can be meaningfully saved. """ def __init__(self, *args, **kwargs): logger.debug('Creating new empty %s instance', type(self)) #: The underlying :class:`~.tags.CompoundTag` for this NBTObject. #: #: Fields store and retrieve instance data in this attribute. It is #: largely used as-is when calling :meth:`to_nbt`, but some fields may #: customize this process in their :meth:`~.fields.AbstractField.save` #: methods. Some NBTObjects will also alter the output by overriding #: :meth:`to_nbt`. For these reasons, direct manipulation of this #: attribute is discouraged. It may still prove useful for debugging #: or for cases where a key is not controlled by any field. self.data = tags.CompoundTag() all_fields = self.all_fields() public_fields = self.fields() missing = object() # sentinel value which is not in kwargs for name in all_fields: if name in public_fields: value = kwargs.pop(name, missing) if value is not missing: logger.debug('Setting %s to %r', name, value) setattr(self, name, value) continue logger.debug('Setting default value for %s', name) field = getattr(type(self), name) field.set_default(self, name) super().__init__(*args, **kwargs)
[docs] def to_nbt(self) -> tags.CompoundTag: """Returns an NBT representation of this object. By default, return self.data, which is sufficient if all data is kept in fields. Also calls the :meth:`~.fields.AbstractField.save` methods of all the fields. """ logger.debug('Converting %r to NBT', self) for name in self.all_fields(): field = getattr(type(self), name) logger.debug('Saving %r', field) field.save(self, name) result = tags.CompoundTag(self.data) return self.prepare_save(result)
[docs] def prepare_save(self, nbt: tags.CompoundTag) -> tags.CompoundTag: """Hook called during :meth:`to_nbt` with the to-be-saved NBT. Should return a :class:`~.tags.CompoundTag` after (for instance) wrapping data inside a singleton CompoundTag or performing other transformations. Unless overridden, return argument unchanged. This hook runs after all fields' :meth:`~.fields.AbstractField.save` methods are called. It is the last hook before the NBT is saved. """ return nbt
@classmethod
[docs] def from_nbt(cls, nbt: tags.CompoundTag): """Creates a new NBTObject from the given NBT representation. Stores the given NBT in the data attribute of the newly-created object. Also calls the :meth:`~.fields.AbstractField.load` methods of all the fields. """ if nbt is None: raise TypeError('Cannot create empty NBTObject via from_nbt') cls._NBTMeta__used = True logger.debug('Creating %s from NBT', cls) nbt = tags.CompoundTag(nbt) nbt = cls.prepare_load(nbt) result = super().__new__(cls) result.data = nbt result._cached = {} for name in cls.all_fields(): field = getattr(cls, name) logger.debug('Loading %r', field) field.load(result, name) return result
@classmethod
[docs] def prepare_load(cls, nbt: tags.CompoundTag) -> tags.CompoundTag: """Hook called during :meth:`from_nbt` with the loaded NBT. Should return a :class:`~.tags.CompoundTag` after (for instance) unwrapping data inside a singleton CompoundTag or performing other transformations. Unless overridden, return argument unchanged. This hook runs before any fields' :meth:`~.fields.AbstractField.load` methods are called. It is the first hook after the NBT has been loaded. .. note:: Unlike :meth:`prepare_save`, this method must be callable as a class method. In practice, both methods can often be implemented as static methods anyway. """ return nbt
[docs] def to_bytes(self) -> bytes: """Serialize this NBTObject directly to a :class:`bytes` object. The root tag will have no name. """ result = io.BytesIO() nbt = self.to_nbt() nbt.encode_named('', result) return result.getvalue()
@classmethod
[docs] def from_bytes(cls, raw: bytes): """Deserialize a new NBTObject from a :class:`bytes` object. The name of the root tag is discarded. """ buf = io.BytesIO(raw) _, nbt = tags.decode_named(buf) return cls.from_nbt(nbt)
@classmethod
[docs] def fields(cls) -> cabc.Set: """Forwards to :meth:`NBTMeta.fields`, which see.""" return type(cls).fields(cls)
@classmethod
[docs] def all_fields(cls) -> cabc.Set: """Forwards to :meth:`NBTMeta.all_fields`, which see.""" return type(cls).all_fields(cls)
def __reduce__(self): return (_from_nbt, (type(self), self.to_nbt(),)) def __repr__(self): return '<NBTObject: {}>'.format(reprlib.repr(self.data))
def _from_nbt(klass, nbt): """Helper function for unpickling. Don't use. """ return klass.from_nbt(nbt)