diff --git a/CHANGELOG.md b/CHANGELOG.md index d9681bba..aeea693c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ -### v0.4.1 -- fix: Session added as optional parameter to enable tls communication with clients ([#249](https://github.com/RWTH-EBC/FiLiP/pull/249)) +### v0.4.2 - add: validation for JEXL based expression ([#260](https://github.com/RWTH-EBC/FiLiP/pull/260)) - add: tutorials for multi-entity ([#260](https://github.com/RWTH-EBC/FiLiP/pull/260)) +### v0.4.1 +- fix: Session added as optional parameter to enable tls communication with clients ([#249](https://github.com/RWTH-EBC/FiLiP/pull/249)) +- fix: add missing package ``geojson_pydantic`` in setup.py ([#276](https://github.com/RWTH-EBC/FiLiP/pull/276)) +- add: support entity creation with keyvalues ([#264](https://github.com/RWTH-EBC/FiLiP/pull/264)) + #### v0.4.0 - add tutorial for protected endpoint with bearer authentication ([#208](https://github.com/RWTH-EBC/FiLiP/issues/208)) - add internal mqtt url for unittests @djs0109 ([#239](https://github.com/RWTH-EBC/FiLiP/pull/239)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d1e567e..bdc2aa04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,44 +1,46 @@ # Contribute as a user The documentation, examples and tutorials should be understandable and the code bug-free. -As all user's have different backgrounds, you may not understand everything or encounter bugs. -In that case, PLEASE raise an issue [here](https://github. com/RWTH-EBC/filip/issues/new). +As all users have different backgrounds, you may not understand everything or encounter bugs. +In that case, PLEASE raise an issue [here](https://github.com/RWTH-EBC/filip/issues/new). -Consider labeling the issue using the flag `bug` or `documentation` / `question`. +Consider labeling the issue with an [appropriate label](https://github.com/RWTH-EBC/FiLiP/labels). # Contribute as a developer If you instead want to contribute new features or fix bugs yourself, we are more than happy. -Please also [raise an issue](https://github.com/RWTH-EBC/filip/issues/new) -create a new branch labeled `XY_some_name`. -Here, `XY` is the number of your issue and `some_name` is a meaningful -description. -Alternatively and preferred, issue branches are created automatically on issue -assignment with [robvanderleek/create-issue-branch](https://github.com/robvanderleek/create-issue-branch). +Please [raise an issue](https://github.com/RWTH-EBC/filip/issues/new). +Issue branches are created automatically on issue assignments. See [workflow definition](.github/workflows/issue-tracker.yml) and [configuration file](.github/issue-branch.yml) for customization. -Branch creation is skipped for issues with label "question". +Branch creation is skipped for issues with the label "question". -Once you're feature is ready, create a pull request and check if the pipeline succeeds. +Once your feature is ready, create a pull request and check if the pipeline succeeds. Assign a reviewer before merging. Once review is finished, you can merge. **Before** implementing or modifying modules, classes or functions, please read the following page. -## Styleguide +## Style guides -We use PEP8 as a styleguide. Some IDEs (like PyCharm) automatically show you code that is not in PEP8. If you don't have such an IDE, please read [this page](https://pep8.org/) to get a better understanding of it. +### Coding style guide + +We use PEP8 as a coding style guide. Some IDEs (like PyCharm) automatically show you code that is not in PEP8. If you don't have such an IDE, please read [this page](https://pep8.org/) to get a better understanding of it. + +### Committing style guide + +For committing style guide please use Conventional Commits 1.0.0. For more details how to structure your commits please visit this [page](https://www.conventionalcommits.org/en/v1.0.0/). ## Documentation -All created or modified function should be documented properly. +All created or modified functions should be documented properly. Try to follow the structure already present. -If possible, write a little doctest example into the docstring to make clear to user's what the desired output of your function is. +If possible, write a little doctest example into the docstring to make clear to the user what the desired output of your function is. All non-self-explanatory lines of code should include a comment. -Although you will notice that not all docstring are already in this style we use the google-style for docstrings, e.g. +Although you will notice that not all docstring are already in this style, we use the google-style for docstrings, e.g. ```python @@ -57,23 +59,25 @@ def foo(dummy: str , dummy2: Union[str, int]): Furthermore, we use type annotations as this helps users to automatically identify wrong usage of functions. -In a further step type annotations may also help to accelerate your code. -For further details please check the official [documentation on type hints](https://docs.python.org/3/library/typing.html). +In a further step, type annotations may also help to accelerate your code. +For more details please check the official [documentation on type hints](https://docs.python.org/3/library/typing.html). ## Unit-Tests -Espacially when creating new functions or classes, you have to add a unit-test function. -Open the `test_module.py` file in the `\tests`-directory and add a function to the class `TestModule`with a name like `test_my_new_function`. If you create a new module, you have to create a new `test_my_new_module.py` file and follow the existing structure of the -other test-files. +Especially when creating new functions or classes, you have to add a unit-test function. +Tests are located in the `\tests` directory. Every file that includes tests has a `test_` prefix. +Open the appropriate module where you want to write a test and add an appropriate function. +When you are adding tests to an existing test file, it is also recommended that you study the other tests in that file; it will teach you which precautions you have to take to make your tests robust and portable. +If the corresponding module does not exist, then you should create a new module with `test_` prefix and appropriate name. If you are not familiar with unit-tests, here is a quick summary: -- Test as many things as possible. Even seemingly silly tests like correct input-format help prevent future problems for new users -- use the `self.assertSOMETHING` functions provided by `unittest`. This way a test failure is presented correctly An error inside your test function will not be handeled as a failure but an error. -- If the success of your test depends on the used device, you can use decorators like `skip()`, `skipif(numpy.__version__<(1, 0), "not supported with your numpy version")`, etc. -- `setUp()` and `tearDown()` are called before and after each test. Use this functions to define parameters used in every test, or to close applications like Dymola once a test is completed. -- See the [unittest-documentation](https://docs.python.org/3/library/unittest.html#organizing-tests) for further information +- Test as many things as possible. Even seemingly silly tests like correct input-format help prevent future problems for new users. +- Use the `self.assertSOMETHING` functions provided by `unittest`. This way a test failure is presented correctly. An error inside your test function will not be handled as a failure but as an error. +- If the success of your test depends on the used development environment, you can use decorators like `skip()`, `skipif(numpy.__version__<(1, 0), "not supported with your numpy version")`, etc. +- `setUp()` and `tearDown()` are called before and after each test. Use these functions to define parameters used in every test, or to close applications like Dymola once a test is completed. +- See the [unittest-documentation](https://docs.python.org/3/library/unittest.html#organizing-tests) for further information. -You can check your work by running all tests before commiting to git. +You can check your work by running all tests before committing to git. ## Pylint With pylint we try to keep our code clean. -See the description in [this repo](https://git.rwth-aachen.de/EBC/EBC_all/gitlab_ci/templates/tree/master/pylint) on information on what pylint is and how to use it. +[Here](https://pypi.org/project/pylint/) you can read more about Pylint and how to use it. diff --git a/examples/README.md b/examples/README.md index f4b9ee30..bbc9bea5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,5 +36,6 @@ The following topics are covered: #### How to use ontologies for semantic system modelling? -- [semantics](https://github.com/RWTH-EBC/FiLiP/tree/master/examples/ngsi_v2/e11_ngsi_v2_semantics) +- [Semantics](https://github.com/RWTH-EBC/FiLiP/tree/master/examples/ngsi_v2/e11_ngsi_v2_semantics) +- [Use-case specific data modeling](https://github.com/RWTH-EBC/FiLiP/tree/master/examples/ngsi_v2/e12_ngsi_v2_use_case_models.py) diff --git a/examples/ngsi_v2/e12_ngsi_v2_use_case_models.py b/examples/ngsi_v2/e12_ngsi_v2_use_case_models.py new file mode 100644 index 00000000..35e9ac81 --- /dev/null +++ b/examples/ngsi_v2/e12_ngsi_v2_use_case_models.py @@ -0,0 +1,156 @@ +""" +# This example shows a workflow, how you can define or reuse use case specific data +# models and ensure FIWARE compatibility by merging these models with existing data +# model in FiLiP. The merged models can be used for interaction with FIWARE platform +# and in other information processing systems to establish interoperability. + +# In short: this workflow shows you a way to keep use case model simple and +# reusable while ensuring the compatability with FIWARE NGSI-V2 standards +""" +from pydantic import ConfigDict, BaseModel +from pydantic.fields import Field, FieldInfo +from filip.models import FiwareHeader +from filip.models.ngsi_v2.context import ContextEntityKeyValues +from filip.clients.ngsi_v2.cb import ContextBrokerClient +from filip.utils.cleanup import clear_context_broker +from pprint import pprint + +# Host address of Context Broker +CB_URL = "http://localhost:1026" + +# You can here also change the used Fiware service +# FIWARE-Service +SERVICE = 'filip' +# FIWARE-Servicepath +SERVICE_PATH = '/' +fiware_header = FiwareHeader(service=SERVICE, + service_path=SERVICE_PATH) + + +# Reuse existing data model from the internet +class PostalAddress(BaseModel): + """ + https://schema.org/PostalAddress + """ + + model_config = ConfigDict(populate_by_name=True, coerce_numbers_to_str=True) + + address_country: str = Field( + alias="addressCountry", + description="County code according to ISO 3166-1-alpha-2", + ) + street_address: str = Field( + alias="streetAddress", + description="The street address. For example, 1600 Amphitheatre Pkwy.", + ) + address_region: str = Field( + alias="addressRegion", + default=None, + ) + address_locality: str = Field( + alias="addressLocality", + default=None, + description="The locality in which the street address is, and which is " + "in the region. For example, Mountain View.", + ) + postal_code: str = Field( + alias="postalCode", + default=None, + description="The postal code. For example, 94043.", + ) + + +# It is assumed that this kind of models exists in use case, which is simple and use case +# specific. It describes basically, how does the data look like in the specific use case. +class WeatherStation(BaseModel): + model_config = ConfigDict(coerce_numbers_to_str=True, extra="ignore") + temperature: float = Field(default=20.0) + humidity: float = Field(default=50.0) + pressure: float = Field(default=1.0) + address: PostalAddress + + +# Merge the use case model with the FIWARE simplified data model to ensure FIWARE +# compatibility. +class WeatherStationFIWARE(WeatherStation, ContextEntityKeyValues): + # add default for type if not explicitly set + type: str = FieldInfo.merge_field_infos( + # First position is the field info of the parent class + ContextEntityKeyValues.model_fields["type"], + # set the default value + default="CustomModels:WeatherStation", + # overwrite the title in the json-schema if necessary + title="Type of the Weather Station", + # overwrite the description + description="Type of the Weather Station", + # validate the default value if necessary + validate_default=True, + # freeze the field if necessary + frozen=True, + # for more options see the pydantic documentation + ) + + +if __name__ == "__main__": + # Now we can export both the use case model and the FIWARE specific + # models to json-schema files and share it with other stakeholders + # or applications/services that need to use the data. + use_case_model = WeatherStation.model_json_schema() + pprint(use_case_model) + + fiware_specific_model = WeatherStationFIWARE.model_json_schema() + pprint(fiware_specific_model) + + # Workflow to utilize these data models. + + # 0. Initial client + cb_client = ContextBrokerClient(url=CB_URL, + fiware_header=fiware_header) + # clear cb + clear_context_broker(cb_client=cb_client) + + # 1. Crate data + weather_station = WeatherStationFIWARE( + id="myWeatherStation", + type="WeatherStation", + temperature=20, + address={ + "address_country": "Germany", + "street_address": "Mathieustr. 10", + "postal_code": 52072, + }, + ) + cb_client.post_entity(entity=weather_station, key_values=True, + update=True) + + # 2. Update data + weather_station.temperature = 30 # represent use case algorithm + cb_client.update_entity_key_values(entity=weather_station) + + # 3. Query and validate data + # represent querying data by data users + weather_station_data = cb_client.get_entity(entity_id="myWeatherStation", + response_format="keyValues") + # validate with general model + weather_station_2_general = WeatherStation.model_validate( + weather_station_data.model_dump() + ) + # validate with fiware specific model + weather_station_2_fiware = WeatherStationFIWARE.model_validate( + weather_station_data.model_dump() + ) + + # 4. Use data for different purposes + # for use case specific usage + print("Data complied with general model can be forwarded to other platform/system:\n" + f"{weather_station_2_general.model_dump_json(indent=2)}") + print(f"For example, address still comply with existing model:\n" + f"{weather_station_2_general.address.model_dump_json(indent=2)}\n") + + # for fiware specific usage + print("For usage within FIWARE system, id and type is helpful, e.g. for creating" + "notification for entity:\n" + f"{weather_station_2_fiware.model_dump_json(indent=2, include={'id', 'type'})}\n") + + # clear cb + clear_context_broker(cb_client=cb_client) diff --git a/filip/__init__.py b/filip/__init__.py index 0916bc06..82b1b4d3 100644 --- a/filip/__init__.py +++ b/filip/__init__.py @@ -4,4 +4,4 @@ from filip.config import settings from filip.clients.ngsi_v2 import HttpClient -__version__ = '0.4.0' +__version__ = '0.4.1' diff --git a/filip/clients/ngsi_v2/cb.py b/filip/clients/ngsi_v2/cb.py index a3599c4c..143746ea 100644 --- a/filip/clients/ngsi_v2/cb.py +++ b/filip/clients/ngsi_v2/cb.py @@ -197,10 +197,11 @@ def get_statistics(self) -> Dict: # Entity Operations def post_entity( self, - entity: ContextEntity, + entity: Union[ContextEntity, ContextEntityKeyValues], update: bool = False, patch: bool = False, override_attr_metadata: bool = True, + key_values: bool = False, ): """ Function registers an Object with the NGSI Context Broker, @@ -215,7 +216,7 @@ def post_entity( patch argument. Args: - entity (ContextEntity): + entity (ContextEntity/ContextEntityKeyValues): Context Entity Object update (bool): If the response.status_code is 422, whether the override and @@ -227,13 +228,27 @@ def post_entity( Only applies for patch equal to `True`. Whether to override or append the attribute's metadata. `True` for overwrite or `False` for update/append - + key_values(bool): + By default False. If set to True, "options=keyValues" will + be included in params of post request. The payload uses + the keyValues simplified entity representation, i.e. + ContextEntityKeyValues. """ url = urljoin(self.base_url, "v2/entities") headers = self.headers.copy() + params = {} + options = [] + if key_values: + assert isinstance(entity, ContextEntityKeyValues) + options.append("keyValues") + else: + assert isinstance(entity, ContextEntity) + if options: + params.update({'options': ",".join(options)}) try: res = self.post( - url=url, headers=headers, json=entity.model_dump(exclude_none=True) + url=url, headers=headers, json=entity.model_dump(exclude_none=True), + params=params, ) if res.ok: self.logger.info("Entity successfully posted!") @@ -242,11 +257,14 @@ def post_entity( except requests.RequestException as err: if update and err.response.status_code == 422: return self.override_entity( - entity=entity) + entity=entity, key_values=key_values) if patch and err.response.status_code == 422: - return self.patch_entity( - entity=entity, override_attr_metadata=override_attr_metadata - ) + if not key_values: + return self.patch_entity( + entity=entity, override_attr_metadata=override_attr_metadata + ) + else: + return self.update_entity_key_values(entity=entity) msg = f"Could not post entity {entity.id}" self.log_error(err=err, msg=msg) raise @@ -740,12 +758,12 @@ def update_or_append_entity_attributes( self.log_error(err=err, msg=msg) raise - def update_entity_key_value(self, - entity: Union[ContextEntityKeyValues, dict],): + def update_entity_key_values(self, + entity: Union[ContextEntityKeyValues, dict],): """ The entity are updated with a ContextEntityKeyValues object or a dictionary contain the simplified entity data. This corresponds to a - 'PATcH' request. + 'PATCH' request. Only existing attribute can be updated! Args: @@ -777,11 +795,11 @@ def update_entity_key_value(self, self.log_error(err=err, msg=msg) raise - def update_entity_attributes_key_value(self, - entity_id: str, - attrs: dict, - entity_type: str = None, - ): + def update_entity_attributes_key_values(self, + entity_id: str, + attrs: dict, + entity_type: str = None, + ): """ Update entity with attributes in keyValues form. This corresponds to a 'PATcH' request. @@ -812,7 +830,7 @@ def update_entity_attributes_key_value(self, "type": entity_type }) entity = ContextEntityKeyValues(**entity_dict) - self.update_entity_key_value(entity=entity) + self.update_entity_key_values(entity=entity) def update_existing_entity_attributes( self, @@ -879,7 +897,10 @@ def update_existing_entity_attributes( self.log_error(err=err, msg=msg) raise - def override_entity(self, entity: ContextEntity): + def override_entity(self, + entity: Union[ContextEntity, ContextEntityKeyValues], + **kwargs + ): """ The request payload is an object representing the attributes to override the existing entity. @@ -888,21 +909,25 @@ def override_entity(self, entity: ContextEntity): If you want to manipulate you should rather use patch_entity. Args: - entity (ContextEntity): + entity (ContextEntity or ContextEntityKeyValues): Returns: None """ - self.replace_entity_attributes(entity_id=entity.id, - entity_type=entity.type, - attrs=entity.get_properties()) + return self.replace_entity_attributes(entity_id=entity.id, + entity_type=entity.type, + attrs=entity.get_attributes(), + **kwargs + ) def replace_entity_attributes( self, entity_id: str, entity_type: str, - attrs: List[Union[NamedContextAttribute, + attrs: Union[List[Union[NamedContextAttribute, Dict[str, ContextAttribute]]], - forcedUpdate: bool = False + Dict], + forcedUpdate: bool = False, + key_values: bool = False, ): """ The attributes previously existing in the entity are removed and @@ -913,11 +938,17 @@ def replace_entity_attributes( entity_id: Entity id to be updated entity_type: Entity type, to avoid ambiguity in case there are several entities with the same entity id. - attrs: List of attributes to add to the entity + attrs: List of attributes to add to the entity or dict of + attributes in case of key_values=True. forcedUpdate: Update operation have to trigger any matching subscription, no matter if there is an actual attribute update or no instead of the default behavior, which is to updated only if attribute is effectively updated. + key_values(bool): + By default False. If set to True, "options=keyValues" will + be included in params of the request. The payload uses + the keyValues simplified entity representation, i.e. + ContextEntityKeyValues. Returns: None """ @@ -927,30 +958,34 @@ def replace_entity_attributes( options = [] if forcedUpdate: options.append("forcedUpdate") + if key_values: + options.append("keyValues") + assert isinstance(attrs, dict) + else: + entity = ContextEntity(id=entity_id, type=entity_type) + entity.add_attributes(attrs) + attrs = entity.model_dump( + exclude={"id", "type"}, + exclude_none=True + ) if options: params.update({'options': ",".join(options)}) if entity_type: params.update({"type": entity_type}) - entity = ContextEntity(id=entity_id, type=entity_type) - entity.add_attributes(attrs) - try: res = self.put( url=url, headers=headers, - json=entity.model_dump( - exclude={"id", "type"}, - exclude_none=True - ), + json=attrs, params=params, ) if res.ok: - self.logger.info("Entity '%s' successfully " "updated!", entity.id) + self.logger.info("Entity '%s' successfully " "updated!", entity_id) else: res.raise_for_status() except requests.RequestException as err: - msg = f"Could not replace attribute of entity {entity.id} !" + msg = f"Could not replace attribute of entity {entity_id} !" self.log_error(err=err, msg=msg) raise @@ -1619,7 +1654,7 @@ def delete_registration(self, registration_id: str) -> None: # Batch operation API def update(self, *, - entities: List[ContextEntity], + entities: List[Union[ContextEntity, ContextEntityKeyValues]], action_type: Union[ActionType, str], update_format: str = None, forcedUpdate: bool = False, @@ -1676,13 +1711,13 @@ def update(self, options.append("overrideMetadata") if forcedUpdate: options.append("forcedUpdate") - if options: - params.update({'options': ",".join(options)}) if update_format: assert ( update_format == "keyValues" ), "Only 'keyValues' is allowed as update format" - params.update({"options": "keyValues"}) + options.append("keyValues") + if options: + params.update({'options': ",".join(options)}) update = Update(actionType=action_type, entities=entities) try: res = self.post( diff --git a/filip/models/ngsi_v2/context.py b/filip/models/ngsi_v2/context.py index fd0a3489..20d28145 100644 --- a/filip/models/ngsi_v2/context.py +++ b/filip/models/ngsi_v2/context.py @@ -63,6 +63,16 @@ class GetEntitiesOptions(str, Enum): ) +class PropertyFormat(str, Enum): + """ + Format to decide if properties of ContextEntity class are returned as + List of NamedContextAttributes or as Dict of ContextAttributes. + """ + + LIST = "list" + DICT = "dict" + + class ContextAttribute(BaseAttribute, BaseValueAttribute): """ Model for an attribute is represented by a JSON object with the following @@ -173,15 +183,15 @@ def __init__(self, id: str, type: Union[str, Enum] = None, **data): # This will result in usual behavior super().__init__(id=id, type=type, **data) + def get_attributes(self) -> dict: + """ + Get the attribute of the entity with the given name in + dict format -class PropertyFormat(str, Enum): - """ - Format to decide if properties of ContextEntity class are returned as - List of NamedContextAttributes or as Dict of ContextAttributes. - """ - - LIST = "list" - DICT = "dict" + Returns: + dict + """ + return self.model_dump(exclude={"id", "type"}) class ContextEntity(ContextEntityKeyValues): @@ -673,7 +683,7 @@ class Update(BaseModel): description="actionType, to specify the kind of update action to do: " "either append, appendStrict, update, delete, or replace. ", ) - entities: List[ContextEntity] = Field( + entities: List[Union[ContextEntity, ContextEntityKeyValues]] = Field( description="an array of entities, each entity specified using the " "JSON entity representation format " ) diff --git a/requirements.txt b/requirements.txt index 5608421b..a52b23fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ requests~=2.31.0 python-dotenv>=0.21.0 pydantic>=2.5.2,<2.7.0 pydantic-settings>=2.0.0,<2.3.0 -geojson_pydantic~=1.0.2 +geojson-pydantic~=1.0.2 aenum~=3.1.15 pathlib~=1.0.1 regex~=2023.10.3 diff --git a/setup.py b/setup.py index 1e7b29fa..ee10f5f7 100644 --- a/setup.py +++ b/setup.py @@ -18,12 +18,13 @@ 'regex~=2023.10.3', 'requests~=2.31.0', 'rapidfuzz~=3.4.0', + 'geojson-pydantic~=1.0.2', 'wget~=3.2', 'pyjexl~=0.3.0'] SETUP_REQUIRES = INSTALL_REQUIRES.copy() -VERSION = '0.4.0' +VERSION = '0.4.1' setuptools.setup( name='filip', diff --git a/tests/clients/test_mqtt_client.py b/tests/clients/test_mqtt_client.py index d127035a..d7a65422 100644 --- a/tests/clients/test_mqtt_client.py +++ b/tests/clients/test_mqtt_client.py @@ -240,7 +240,7 @@ def on_command(client, obj, msg): entity_type=entity.type, command=context_command) - time.sleep(2) + time.sleep(5) # close the mqtt listening thread self.mqttc.loop_stop() # disconnect the mqtt device diff --git a/tests/clients/test_ngsi_v2_cb.py b/tests/clients/test_ngsi_v2_cb.py index 7e6cf967..e1fbe74d 100644 --- a/tests/clients/test_ngsi_v2_cb.py +++ b/tests/clients/test_ngsi_v2_cb.py @@ -679,11 +679,12 @@ def on_disconnect(client, userdata, reasonCode, properties=None): mqtt_client.loop_start() new_value = 50 + time.sleep(1) self.client.update_attribute_value(entity_id=entity.id, attr_name='temperature', value=new_value, entity_type=entity.type) - time.sleep(5) + time.sleep(1) # test if the subscriptions arrives and the content aligns with updates self.assertIsNotNone(sub_message) @@ -693,6 +694,40 @@ def on_disconnect(client, userdata, reasonCode, properties=None): mqtt_client.disconnect() time.sleep(1) + @clean_test(fiware_service=settings.FIWARE_SERVICE, + fiware_servicepath=settings.FIWARE_SERVICEPATH, + cb_url=settings.CB_URL) + def test_override_entity_keyvalues(self): + entity1 = self.entity.model_copy(deep=True) + # initial entity + self.client.post_entity(entity1) + + # entity with key value + entity1_key_value = self.client.get_entity( + entity_id=entity1.id, + response_format=AttrsFormat.KEY_VALUES) + + # override entity with ContextEntityKeyValues + entity1_key_value.temperature = 30 + self.client.override_entity(entity=entity1_key_value, key_values=True) + self.assertEqual(entity1_key_value, + self.client.get_entity( + entity_id=entity1.id, + response_format=AttrsFormat.KEY_VALUES) + ) + # test replace all attributes + entity1_key_value_dict = entity1_key_value.model_dump() + entity1_key_value_dict["temp"] = 40 + entity1_key_value_dict["humidity"] = 50 + self.client.override_entity( + entity=ContextEntityKeyValues(**entity1_key_value_dict), + key_values=True) + self.assertEqual(entity1_key_value_dict, + self.client.get_entity( + entity_id=entity1.id, + response_format=AttrsFormat.KEY_VALUES).model_dump() + ) + @clean_test(fiware_service=settings.FIWARE_SERVICE, fiware_servicepath=settings.FIWARE_SERVICEPATH, cb_url=settings.CB_URL) @@ -708,7 +743,7 @@ def test_update_entity_keyvalues(self): # update entity with ContextEntityKeyValues entity1_key_value.temperature = 30 - self.client.update_entity_key_value(entity=entity1_key_value) + self.client.update_entity_key_values(entity=entity1_key_value) self.assertEqual(entity1_key_value, self.client.get_entity( entity_id=entity1.id, @@ -721,10 +756,7 @@ def test_update_entity_keyvalues(self): # update entity with dictionary entity1_key_value_dict = entity1_key_value.model_dump() entity1_key_value_dict["temperature"] = 40 - self.client.update_entity_key_value(entity=entity1_key_value_dict) - self.client.get_entity( - entity_id=entity1.id, - response_format=AttrsFormat.KEY_VALUES).model_dump() + self.client.update_entity_key_values(entity=entity1_key_value_dict) self.assertEqual(entity1_key_value_dict, self.client.get_entity( entity_id=entity1.id, @@ -735,7 +767,7 @@ def test_update_entity_keyvalues(self): entity3.temperature.type) entity1_key_value_dict.update({"humidity": 50}) with self.assertRaises(RequestException): - self.client.update_entity_key_value(entity=entity1_key_value_dict) + self.client.update_entity_key_values(entity=entity1_key_value_dict) @clean_test(fiware_service=settings.FIWARE_SERVICE, fiware_servicepath=settings.FIWARE_SERVICEPATH, @@ -961,6 +993,22 @@ def test_batch_operations(self): self.assertEqual(1000, len(client.query(query=query, response_format='keyValues'))) + # update with keyValues + entities_keyvalues = [ContextEntityKeyValues(id=str(i), + type=f'filip:object:TypeC', + attr1="text attribute", + attr2=1 + ) for i in range(0, 1000)] + client.update(entities=entities_keyvalues, + update_format="keyValues", + action_type=ActionType.APPEND) + entity_keyvalues = EntityPattern(idPattern=".*", typePattern=".*TypeC$") + query_keyvalues = Query.model_validate( + {"entities": [entity_keyvalues.model_dump(exclude_unset=True)]}) + entities_keyvalues_query = client.query(query=query_keyvalues, + response_format='keyValues') + self.assertEqual(1000, len(entities_keyvalues_query)) + self.assertEqual(1000, sum([e.attr2 for e in entities_keyvalues_query])) def test_force_update_option(self): """