.. _entities_ops: Entities and Operations ======================= .. testsetup:: * from ska_sdp_config import Config config = Config(backend="memory", global_prefix="/docs_tests") config.backend.delete("/docs_tests", recursive=True, must_exist=False) txn_iter = iter(config.txn()) txn = next(txn_iter) .. testcleanup:: * config.close() .. note:: All code examples below work on a ``txn`` |Txn| object created like this: .. code-block:: python for txn in Config(backend="memory").txn(): # example code .. _overview: Overview -------- .. py:currentmodule:: ska_sdp_config.config The SDP Configuration Database manages *entities* of different *types*. Each type of entity is stored under a well-determined *prefix* or *path*. Individual entities within a certain type are identified by a *key*, which is composed by one or more *key parts*. Each key part accepts values that are constrained by a *pattern*. Additionally, entities of certain types have an associated *owner* and *state*. An entity is said to be *alive* if its owner entry exists in the database. The |Txn| class provides a series of attributes (e.g., :attr:`Transaction.deployment`) through which users are meant to operate on the different entities that are stored in the database. This section describes how this interaction takes place. Entity models ------------- .. note:: Support for entity model classes has been added, but not all entities have been modelled. Check the :ref:`supported_entities` section for information of which entity types are currently modelled. Long-term the aim is to model all of them. Historically the SDP Configuration Database has dealt with plain ``dict`` objects as entity values. On top of that, two classes modelling :class:`~ska_sdp_config.entity.pb.ProcessingBlock` and :class:`~ska_sdp_config.entity.deployment.Deployment` had been provided to users allowing for conversion from/to dictionaries for operating with the (old, ``dict``-based) :class:`Transaction` API. Entities now are expected to be modelled as individual classes using `pydantic `_. This has several benefits: * Provides users with a well-defined in-memory representation for each entity type. * Makes it possible to easily distinguish between entities of different types. * Allows to model restrictions on each field, triggering validation errors when invalid content is provided. When an entity is modelled using a ``pydantic`` model class, the corresponding operations described below are defined in terms of instances of that class rather than plain dictionaries. The model class also defines the entity key as a field that should be modelled itself with ``pydantic``. Operating on entities --------------------- To access entities of a particular type users need to operate on the corresponding attribute of |Txn|. See :ref:`supported_entities` for a list of all supported entities and their corresponding attributes. For example, to operate on processing block entities one selects the :attr:`~Transaction.processing_block` attribute: .. doctest:: overview >>> txn.processing_block # doctest: +ELLIPSIS These attributes provide *operations* on entities of that type. There are two types of operations: those that act over individual entities, and those that operate over the whole set of entities for a given entity type. The ``self`` attribute ^^^^^^^^^^^^^^^^^^^^^^ The |Txn| class has a special :attr:`~Transaction.self` attribute pointing to the operations for the entity pointed to by the ``owned_entity`` argument of the :class:`Config` constructor. The argument is a two-tuple with: * The entity type as a :class:`Transaction` attribute name * The full key of the entity in question. See :ref:`supported_entities` for references for both values. For example, the following :class:`Config` class is configured to get convenient access to the ``test`` :attr:`~Transaction.component`: .. testcode:: self cfg2 = Config(backend="memory", owned_entity=("component", "test")) for txn in cfg2.txn(): print(txn.self.path) .. testoutput:: self /component/test If no identity is provided (like in the case of the :class:`Config` object used in these examples), :attr:`~Transaction.self` will be ``None``. .. doctest:: no-self >>> txn.self is None True ``dict`` entities ----------------- .. _indexing_dict: Indexing ^^^^^^^^ To operate on an individual entity modelled by a plain dictionary users need to "index" into the entity type to identify the entity of interest. This indexing is done by invoking the corresponding attribute with the key information that identifies an individual entity. For example, to operate over the Controller device component identified by ``"lmc-controller"`` one calls the :attr:`~Transaction.component` attribute with the key: .. doctest:: indexing >>> txn.component(key="lmc-controller") >>> txn.component(component_name="lmc-controller") >>> txn.component("lmc-controller") Note how here we can provide either: * The special ``key`` keyword argument. This specifies the *full* key as a single value (see :ref:`overview` for the distinction between *key* and *key parts*). For entities with a single key part (like component) this is no different than giving the key part value, but for entities with multiple key parts it means that a single value can be used to index. If ``key`` is given, no other keyword arguments are accepted. * The ``component_name`` keyword argument. This keyword argument is accepted because its name matches one of the key parts (and the only one). * Since components have only a single key part, the value for this single key part can also be given as a single positional parameter. Incorrect indexing results in errors raised even before trying to hit the database: .. doctest:: indexing >>> txn.component(key="lmc-controller", component_name="lmc-subarray-01") Traceback (most recent call last): ValueError: 'key' cannot be combined with other keyword arguments >>> txn.component(wrong_key_part="lmc-controller") Traceback (most recent call last): ValueError: "wrong_key_part" is not a valid key part under /component >>> txn.component("lmc-controller", "lmc-subarray-01") Traceback (most recent call last): ValueError: Only single positional argument can be given Note also that providing key or key part values that don't adhere to the key part patterns supported by the entity also results in errors: .. doctest:: indexing >>> txn.component(key="invalid&char") Traceback (most recent call last): ska_sdp_config.operations.entity_operations.InvalidKey: Invalid key='invalid&char' for pattern=re.compile('^(?P[a-zA-Z0-9_-]+)$') >>> txn.component(component_name="invalid&char") Traceback (most recent call last): ValueError: "invalid&char" is not a valid value for key part "component_name" under /component >>> txn.component("invalid&char") Traceback (most recent call last): ValueError: "invalid&char" is not a valid value for key part "component_name" under /component See the :doc:`API documentation ` to see all patterns in detail. .. _entity_ops: Entity operations ^^^^^^^^^^^^^^^^^ .. py:currentmodule:: ska_sdp_config.operations.entity_operations Once an entity has been indexed into, all its operations become available: * The :attr:`~BaseEntityOperations.key`, :attr:`~BaseEntityOperations.key_parts` and :attr:`~BaseEntityOperations.path` attributes inform users of the key, key parts (names and values) and database path for the particular entity. * The :meth:`~BaseEntityOperations.create`, :meth:`~BaseEntityOperations.get`, :meth:`~BaseEntityOperations.update` and :meth:`~BaseEntityOperations.delete` methods allow users to perform basic `CRUD `_ operations. * The :meth:`~BaseEntityOperations.create_or_update` and :meth:`~BaseEntityOperations.exists` methods are small utilities built on top of the basic methods. Additionally, if an entity type might give access to two extra attributes: * :attr:`~OwnedEntityOperationsMixIn.ownership` is available on entity types that have an owner (that is, a process in the system that is in charge of them). It offers the basic :meth:`~OwnershipOperations.take` and :meth:`~OwnershipOperations.get` methods, together with the utility :meth:`~OwnershipOperations.is_owned` and :meth:`~OwnershipOperations.is_owned_by_this_process` ones. Additionally, the :meth:`~OwnedEntityOperationsMixIn.is_alive` and :meth:`~OwnedEntityOperationsMixIn.take_ownership_if_not_alive` methods are also provided. * :attr:`~StatefulEntityOperationsMixIn.state` is available on entity types that have an associated state. It offers the basic :meth:`~StateOperations.create`, :meth:`~StateOperations.update` and :meth:`~StateOperations.get` methods. .. _pydantic_entities: ``pydantic`` entities --------------------- .. _indexing_pydantic: Indexing ^^^^^^^^ For entities that are modelled via ``pydantic``, manual indexing with strings as shown above is still possible, but not encouraged. Instead, high-level functions are provided that take a key, or a value, or either, and they internally perform the necessary indexing. See :ref:`entity_ops_pydantic` (below) for more information. To enforce this expected API usage when performing manual indexing, users must use the :meth:`~CollectiveEntityOperations.index_by_key_parts` method explicitly instead of using the magic ``__call__`` function: .. doctest:: indexing-with-model >>> from ska_sdp_config.entity import Deployment >>> txn.deployment.index_by_key_parts(key="my-deployment") .. _entity_ops_pydantic: Entity operations ^^^^^^^^^^^^^^^^^ If an entity is modelled using ``pydantic`` then all of the operations listed above for ``dict`` entities are also available directly at the corresponding |Txn| attribute. and require a entity value (or key) as argument. For writing operations it is convenient to specify the entity itself as argument, since the full value **needs** to be given anyway. For the rest, only the key parts from the entity are extracted and used, with the rest ignored, and in those cases the methods can accept the key as argument instead. For example, the following shows the different indexing methods with the ``deployment`` entity: .. doctest:: entity-ops-for-pydantic >>> from ska_sdp_config.entity import Deployment >>> deployment1 = Deployment(key="my-deployment1", kind="helm", args={}) >>> deployment2 = Deployment(key="my-deployment2", kind="helm", args={}) # Indexing happens as part of the operation # Here we can index by key or by value to check if the deployment exists >>> txn.deployment.exists("my-deployment1") False >>> txn.deployment.exists(deployment2) False # Writing operations require a full value; indexing still happens by key only >>> txn.deployment.create(deployment1) >>> txn.deployment.create(deployment2) # Check both were created >>> len(txn.deployment.list_keys()) 2 # Reading via top-level get() can be indexed by value or by key >>> all(txn.deployment.get(dpl) for dpl in (deployment1, deployment2)) True >>> all(txn.deployment.get(key) for key in ("my-deployment1", "my-deployment2")) True Collective operations --------------------- Other operations are performed over the entire entity type. As such, these operations are directly available on the corresponding |Txn| attribute. The only operations currently supported over a whole entity type is querying. To query entities of a given type, users invoke the :meth:`~MultiEntityOperations.query_keys`, :meth:`~MultiEntityOperations.list_keys`, :meth:`~MultiEntityOperations.query_values` or :meth:`~MultiEntityOperations.list_values` methods on the corresponding attribute. By default they all return all keys/values for the given entity type. Constrains can be given in the form of keyword arguments that must match the entity's key parts. For entities that are modelled with ``pydantic``, the returned keys and values are instances of the corresponding model. If entities are modelled with plain dictionaries, keys are strings and values are dictionaries. For example, this code queries all subarray keys: .. doctest:: queries >>> txn.component.list_keys() [] >>> txn.component("lmc-controller").create({"value": 1}) >>> txn.component("lmc-subarray-01").create({"value": 2}) >>> sorted(txn.component.list_keys()) ['lmc-controller', 'lmc-subarray-01'] If we wanted keys *and* values for all components whose IDs start with ``lmc`` we'd write: .. doctest:: queries >>> txn.component.list_values(component_name_prefix="lmc") [('lmc-controller', {'value': 1}), ('lmc-subarray-01', {'value': 2})] The next example creates a whole set of scripts, which are modelled with ``pydantic``, then iterates over the first 5 ``vis-receive`` scripts with version ``4.2.*``: .. testcode:: queries from ska_sdp_config.entity import Script from itertools import islice, product # Create loads of scripts names = ("vis-receive", "test-recv-addresses") for name, major, minor, patch in product(names, range(10), range(10), range(10)): full_version = f"{major}.{minor}.{patch}" key = Script.Key(name=name, version=full_version, kind="realtime") image = f"{name}:{full_version}" sdp_version = "==0.21.0" script = Script(key=key, image=image, sdp_version=sdp_version) txn.script.create(script) print(f"There are {len(txn.script.list_keys())} scripts") # Query only some print(f"First five vis-receive scripts with version 4.2.* follow:") lazy_query = txn.script.query_values(name="vis-receive", version_prefix="4.2.") for key, value in islice(lazy_query, 5): print(f"{key=}, {value=}") .. testoutput:: queries There are 2000 scripts First five vis-receive scripts with version 4.2.* follow: key=Key(kind='realtime', name='vis-receive', version='4.2.0'), value=Script(key=Key(kind='realtime', name='vis-receive', version='4.2.0'), image='vis-receive:4.2.0', parameters={}, sdp_version='==0.21.0', resources=[]) key=Key(kind='realtime', name='vis-receive', version='4.2.1'), value=Script(key=Key(kind='realtime', name='vis-receive', version='4.2.1'), image='vis-receive:4.2.1', parameters={}, sdp_version='==0.21.0', resources=[]) key=Key(kind='realtime', name='vis-receive', version='4.2.2'), value=Script(key=Key(kind='realtime', name='vis-receive', version='4.2.2'), image='vis-receive:4.2.2', parameters={}, sdp_version='==0.21.0', resources=[]) key=Key(kind='realtime', name='vis-receive', version='4.2.3'), value=Script(key=Key(kind='realtime', name='vis-receive', version='4.2.3'), image='vis-receive:4.2.3', parameters={}, sdp_version='==0.21.0', resources=[]) key=Key(kind='realtime', name='vis-receive', version='4.2.4'), value=Script(key=Key(kind='realtime', name='vis-receive', version='4.2.4'), image='vis-receive:4.2.4', parameters={}, sdp_version='==0.21.0', resources=[]) .. _supported_entities: Supported entities ------------------ .. py:currentmodule:: ska_sdp_config.config The table below summarises the supported entities and their operations. Note that for those entities modelled via ``pydantic`` there is a model class, while for plain ``dict`` entities there are key parts. .. list-table:: Entities supported by |Txn| :header-rows: 1 * - Attribute - Prefix/path - Model class - Key parts - Has ``ownership``? - Has ``state``? * - :attr:`~Transaction.component` - ``/component`` - -- - ``component_name`` - ``True`` - ``False`` * - :attr:`~Transaction.system` - ``/system`` - :class:`~ska_sdp_config.entity.system.System` - -- - ``False`` - ``False`` * - :attr:`~Transaction.deployment` - ``/deploy`` - :class:`~ska_sdp_config.entity.deployment.Deployment` - -- - ``False`` - ``True`` * - :attr:`~Transaction.execution_block` - ``/eb`` - :class:`~ska_sdp_config.entity.eb.ExecutionBlock` - -- - ``False`` - ``True`` * - :attr:`~Transaction.processing_block` - ``/pb`` - :class:`~ska_sdp_config.entity.pb.ProcessingBlock` - -- - ``True`` - ``True`` * - :attr:`~Transaction.script` - ``/script`` - :class:`~ska_sdp_config.entity.script.Script` - -- - ``False`` - ``False`` * - :attr:`~Transaction.flow` - ``/flow`` - :class:`~ska_sdp_config.entity.flow.Flow` - -- - ``True`` - ``True`` * - :attr:`~Transaction.dependency` - ``/dependency`` - :class:`~ska_sdp_config.entity.flow.Dependency` - -- - ``False`` - ``True`` * - :attr:`~Transaction.resource` - ``/resource`` - :class:`~ska_sdp_config.entity.resource_management.Resource` - -- - ``False`` - ``True`` * - :attr:`~Transaction.request` - ``/request`` - :class:`~ska_sdp_config.entity.resource_management.Request` - -- - ``False`` - ``True`` * - :attr:`~Transaction.allocation` - ``/allocation`` - :class:`~ska_sdp_config.entity.resource_management.Allocation` - -- - ``False`` - ``True`` Arbitrary path access --------------------- On top of the entity-specific access provided by |Txn|, there is also an extra :attr:`~Transaction.arbitrary` attribute that allows operating over any arbitrary path in the database. This is useful for tools performing operations over generic paths, or to quickly experiment with storing data under certain paths before committing to adding a new entity in this package. To access a particular path users need to index into :attr:`~Transaction.arbitrary` as described in :ref:`indexing_dict`, although here a single positional argument indicates the path. The result of that call is the set of operations described in :ref:`entity_ops`. The following example uses arbitrary path access to first write into ``"/my_path"``, then obtain the value that has just been written: .. testsetup:: arbitrary import warnings .. testcode:: arbitrary with warnings.catch_warnings(record=True) as warns: txn.arbitrary("/my_path").create({"my_value": 3}) print(txn.arbitrary("/my_path").get()) print(f"{len(warns)} warnings generated") .. testoutput:: arbitrary {'my_value': 3} 2 warnings generated The example above is accessing ``/my_path``, which is not a path that is handled by any of the supported entities. In those cases, a warning is raised so that users are aware of this unofficial access. .. note:: This arbitrary path support shouldn’t be abused as a long-term solution if the goal is to introduce a new entity to the system. New entities should be explicitly added to this package and used instead. .. |Txn| replace:: :class:`Transaction` Accessing known paths raises no warnings: .. testcode:: arbitrary txn.component("lmc-subarray-01").create({"value": 1}) with warnings.catch_warnings(record=True) as warns: print(f'Subarray: {txn.arbitrary("/component/lmc-subarray-01").get()}') print(f"{len(warns)} warnings generated") .. testoutput:: arbitrary Subarray: {'value': 1} 0 warnings generated