diff --git a/CHANGELOG.md b/CHANGELOG.md index a12b708a36..c28ac6ec48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Changes are grouped as follows - `Fixed` for any bug fixes. - `Security` in case of vulnerabilities. +## [7.62.0] - 2024-09-19 +### Added +- All `update` methods now accept a new parameter `mode` that controls how non-update objects should be + interpreted. For example, should we do a partial update or a full replacement. + ## [7.61.1] - 2024-09-19 ### Added - [Feature Preview - alpha] Support for `client.hosted_extractors.jobs`. diff --git a/cognite/client/_api/annotations.py b/cognite/client/_api/annotations.py index 953f03ceda..9ca86b0c47 100644 --- a/cognite/client/_api/annotations.py +++ b/cognite/client/_api/annotations.py @@ -113,10 +113,18 @@ def _convert_resource_to_patch_object( return annotation_update.dump() @overload - def update(self, item: Annotation | AnnotationWrite | AnnotationUpdate) -> Annotation: ... + def update( + self, + item: Annotation | AnnotationWrite | AnnotationUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Annotation: ... @overload - def update(self, item: Sequence[Annotation | AnnotationWrite | AnnotationUpdate]) -> AnnotationList: ... + def update( + self, + item: Sequence[Annotation | AnnotationWrite | AnnotationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> AnnotationList: ... def update( self, @@ -124,16 +132,23 @@ def update( | AnnotationWrite | AnnotationUpdate | Sequence[Annotation | AnnotationWrite | AnnotationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Annotation | AnnotationList: """`Update annotations `_ Args: item (Annotation | AnnotationWrite | AnnotationUpdate | Sequence[Annotation | AnnotationWrite | AnnotationUpdate]): Annotation or list of annotations to update (or patch or list of patches to apply) + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (Annotation or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Annotation | AnnotationList: No description.""" return self._update_multiple( - list_cls=AnnotationList, resource_cls=Annotation, update_cls=AnnotationUpdate, items=item + list_cls=AnnotationList, resource_cls=Annotation, update_cls=AnnotationUpdate, items=item, mode=mode ) def delete(self, id: int | Sequence[int]) -> None: diff --git a/cognite/client/_api/assets.py b/cognite/client/_api/assets.py index 13f60b733a..9f04c8b8fd 100644 --- a/cognite/client/_api/assets.py +++ b/cognite/client/_api/assets.py @@ -755,20 +755,35 @@ def delete( ) @overload - def update(self, item: Sequence[Asset | AssetWrite | AssetUpdate]) -> AssetList: ... + def update( + self, + item: Sequence[Asset | AssetWrite | AssetUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> AssetList: ... @overload - def update(self, item: Asset | AssetWrite | AssetUpdate) -> Asset: ... + def update( + self, + item: Asset | AssetWrite | AssetUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Asset: ... def update( - self, item: Asset | AssetWrite | AssetUpdate | Sequence[Asset | AssetWrite | AssetUpdate] + self, + item: Asset | AssetWrite | AssetUpdate | Sequence[Asset | AssetWrite | AssetUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Asset | AssetList: """`Update one or more assets `_ Labels can be added, removed or replaced (set). Note that set operation deletes all the existing labels and adds the new specified labels. Args: item (Asset | AssetWrite | AssetUpdate | Sequence[Asset | AssetWrite | AssetUpdate]): Asset(s) to update - + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (Asset or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Asset | AssetList: Updated asset(s) @@ -820,7 +835,9 @@ def update( >>> my_update = AssetUpdate(id=1).labels.set("PUMP") >>> res = client.assets.update(my_update) """ - return self._update_multiple(list_cls=AssetList, resource_cls=Asset, update_cls=AssetUpdate, items=item) + return self._update_multiple( + list_cls=AssetList, resource_cls=Asset, update_cls=AssetUpdate, items=item, mode=mode + ) @overload def upsert(self, item: Sequence[Asset | AssetWrite], mode: Literal["patch", "replace"] = "patch") -> AssetList: ... diff --git a/cognite/client/_api/data_sets.py b/cognite/client/_api/data_sets.py index 471a6183c2..b054a44487 100644 --- a/cognite/client/_api/data_sets.py +++ b/cognite/client/_api/data_sets.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload +from typing import TYPE_CHECKING, Any, Iterator, Literal, Sequence, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -213,18 +213,34 @@ def aggregate(self, filter: DataSetFilter | dict[str, Any] | None = None) -> lis return self._aggregate(filter=filter, cls=CountAggregate) @overload - def update(self, item: DataSet | DataSetWrite | DataSetUpdate) -> DataSet: ... + def update( + self, + item: DataSet | DataSetWrite | DataSetUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> DataSet: ... @overload - def update(self, item: Sequence[DataSet | DataSetWrite | DataSetUpdate]) -> DataSetList: ... + def update( + self, + item: Sequence[DataSet | DataSetWrite | DataSetUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> DataSetList: ... def update( - self, item: DataSet | DataSetWrite | DataSetUpdate | Sequence[DataSet | DataSetWrite | DataSetUpdate] + self, + item: DataSet | DataSetWrite | DataSetUpdate | Sequence[DataSet | DataSetWrite | DataSetUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> DataSet | DataSetList: """`Update one or more data sets `_ Args: item (DataSet | DataSetWrite | DataSetUpdate | Sequence[DataSet | DataSetWrite | DataSetUpdate]): Data set(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (DataSet or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: DataSet | DataSetList: Updated data set(s) @@ -247,7 +263,9 @@ def update( >>> my_update = DataSetUpdate(id=1).description.set("New description").metadata.remove(["key"]) >>> res = client.data_sets.update(my_update) """ - return self._update_multiple(list_cls=DataSetList, resource_cls=DataSet, update_cls=DataSetUpdate, items=item) + return self._update_multiple( + list_cls=DataSetList, resource_cls=DataSet, update_cls=DataSetUpdate, items=item, mode=mode + ) def list( self, diff --git a/cognite/client/_api/datapoints_subscriptions.py b/cognite/client/_api/datapoints_subscriptions.py index ef4b819570..d676d3af5e 100644 --- a/cognite/client/_api/datapoints_subscriptions.py +++ b/cognite/client/_api/datapoints_subscriptions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, cast, overload +from typing import TYPE_CHECKING, Iterator, Literal, cast, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -192,7 +192,11 @@ def list_member_time_series(self, external_id: str, limit: int | None = DEFAULT_ other_params={"externalId": external_id}, ) - def update(self, update: DataPointSubscriptionUpdate | DataPointSubscriptionWrite) -> DatapointSubscription: + def update( + self, + update: DataPointSubscriptionUpdate | DataPointSubscriptionWrite, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> DatapointSubscription: """`Update a subscriptions `_ Update a subscription. Note that Fields that are not included in the request are not changed. @@ -200,6 +204,12 @@ def update(self, update: DataPointSubscriptionUpdate | DataPointSubscriptionWrit Args: update (DataPointSubscriptionUpdate | DataPointSubscriptionWrite): The subscription update. + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (DataPointSubscriptionWrite). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. Returns: DatapointSubscription: Updated subscription. @@ -229,6 +239,7 @@ def update(self, update: DataPointSubscriptionUpdate | DataPointSubscriptionWrit list_cls=DatapointSubscriptionList, resource_cls=DatapointSubscription, update_cls=DataPointSubscriptionUpdate, + mode=mode, ) def iterate_data( diff --git a/cognite/client/_api/entity_matching.py b/cognite/client/_api/entity_matching.py index 758eecede2..5bed0e5bf5 100644 --- a/cognite/client/_api/entity_matching.py +++ b/cognite/client/_api/entity_matching.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Sequence, TypeVar +from typing import Any, Literal, Sequence, TypeVar from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -89,11 +89,18 @@ def update( item: EntityMatchingModel | EntityMatchingModelUpdate | Sequence[EntityMatchingModel | EntityMatchingModelUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> EntityMatchingModelList | EntityMatchingModel: """`Update model `_ Args: item (EntityMatchingModel | EntityMatchingModelUpdate | Sequence[EntityMatchingModel | EntityMatchingModelUpdate]): Model(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (EntityMatchingModel). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: EntityMatchingModelList | EntityMatchingModel: No description. @@ -109,6 +116,7 @@ def update( resource_cls=EntityMatchingModel, update_cls=EntityMatchingModelUpdate, items=item, + mode=mode, ) def list( diff --git a/cognite/client/_api/events.py b/cognite/client/_api/events.py index db94558844..9c56e3f378 100644 --- a/cognite/client/_api/events.py +++ b/cognite/client/_api/events.py @@ -555,18 +555,34 @@ def delete( ) @overload - def update(self, item: Sequence[Event | EventWrite | EventUpdate]) -> EventList: ... + def update( + self, + item: Sequence[Event | EventWrite | EventUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> EventList: ... @overload - def update(self, item: Event | EventWrite | EventUpdate) -> Event: ... + def update( + self, + item: Event | EventWrite | EventUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Event: ... def update( - self, item: Event | EventWrite | EventUpdate | Sequence[Event | EventWrite | EventUpdate] + self, + item: Event | EventWrite | EventUpdate | Sequence[Event | EventWrite | EventUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Event | EventList: """`Update one or more events `_ Args: item (Event | EventWrite | EventUpdate | Sequence[Event | EventWrite | EventUpdate]): Event(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (Event or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Event | EventList: Updated event(s) @@ -589,7 +605,9 @@ def update( >>> my_update = EventUpdate(id=1).description.set("New description").metadata.add({"key": "value"}) >>> res = client.events.update(my_update) """ - return self._update_multiple(list_cls=EventList, resource_cls=Event, update_cls=EventUpdate, items=item) + return self._update_multiple( + list_cls=EventList, resource_cls=Event, update_cls=EventUpdate, items=item, mode=mode + ) def search( self, diff --git a/cognite/client/_api/extractionpipelines.py b/cognite/client/_api/extractionpipelines.py index 26a4e8a77b..65650f2c1b 100644 --- a/cognite/client/_api/extractionpipelines.py +++ b/cognite/client/_api/extractionpipelines.py @@ -245,11 +245,18 @@ def update( | ExtractionPipelineWrite | ExtractionPipelineUpdate | Sequence[ExtractionPipeline | ExtractionPipelineWrite | ExtractionPipelineUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> ExtractionPipeline | ExtractionPipelineList: """`Update one or more extraction pipelines `_ Args: item (ExtractionPipeline | ExtractionPipelineWrite | ExtractionPipelineUpdate | Sequence[ExtractionPipeline | ExtractionPipelineWrite | ExtractionPipelineUpdate]): Extraction pipeline(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (ExtractionPipeline or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: ExtractionPipeline | ExtractionPipelineList: Updated extraction pipeline(s) @@ -270,6 +277,7 @@ def update( resource_cls=ExtractionPipeline, update_cls=ExtractionPipelineUpdate, items=item, + mode=mode, ) diff --git a/cognite/client/_api/files.py b/cognite/client/_api/files.py index 97722c6563..745d9ffbd1 100644 --- a/cognite/client/_api/files.py +++ b/cognite/client/_api/files.py @@ -6,7 +6,7 @@ from collections import defaultdict from io import BufferedReader from pathlib import Path -from typing import Any, BinaryIO, Iterator, Sequence, TextIO, cast, overload +from typing import Any, BinaryIO, Iterator, Literal, Sequence, TextIO, cast, overload from urllib.parse import urljoin, urlparse from cognite.client._api_client import APIClient @@ -360,10 +360,18 @@ def delete( ) @overload - def update(self, item: FileMetadata | FileMetadataWrite | FileMetadataUpdate) -> FileMetadata: ... + def update( + self, + item: FileMetadata | FileMetadataWrite | FileMetadataUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> FileMetadata: ... @overload - def update(self, item: Sequence[FileMetadata | FileMetadataWrite | FileMetadataUpdate]) -> FileMetadataList: ... + def update( + self, + item: Sequence[FileMetadata | FileMetadataWrite | FileMetadataUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> FileMetadataList: ... def update( self, @@ -371,12 +379,14 @@ def update( | FileMetadataWrite | FileMetadataUpdate | Sequence[FileMetadata | FileMetadataWrite | FileMetadataUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> FileMetadata | FileMetadataList: """`Update files `_ Currently, a full replacement of labels on a file is not supported (only partial add/remove updates). See the example below on how to perform partial labels update. Args: item (FileMetadata | FileMetadataWrite | FileMetadataUpdate | Sequence[FileMetadata | FileMetadataWrite | FileMetadataUpdate]): file(s) to update. + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update object is given (FilesMetadata or -Write). If you use 'replace_ignore_null', only the fields you have set will be used to replace existing (default). Using 'replace' will additionally clear all the fields that are not specified by you. Last option, 'patch', will update only the fields you have set and for container-like fields such as metadata or labels, add the values to the existing. For more details, see :ref:`appendix-update`. Returns: FileMetadata | FileMetadataList: The updated files. @@ -429,6 +439,7 @@ def update( resource_path=self._RESOURCE_PATH, items=item, headers=headers, + mode=mode, ) def search( diff --git a/cognite/client/_api/hosted_extractors/destinations.py b/cognite/client/_api/hosted_extractors/destinations.py index f41b0ec9b9..75703fe270 100644 --- a/cognite/client/_api/hosted_extractors/destinations.py +++ b/cognite/client/_api/hosted_extractors/destinations.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Sequence, overload +from typing import TYPE_CHECKING, Any, Literal, Sequence, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -193,18 +193,34 @@ def create(self, items: DestinationWrite | Sequence[DestinationWrite]) -> Destin ) @overload - def update(self, items: DestinationWrite | DestinationUpdate) -> Destination: ... + def update( + self, + items: DestinationWrite | DestinationUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Destination: ... @overload - def update(self, items: Sequence[DestinationWrite | DestinationUpdate]) -> DestinationList: ... + def update( + self, + items: Sequence[DestinationWrite | DestinationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> DestinationList: ... def update( - self, items: DestinationWrite | DestinationUpdate | Sequence[DestinationWrite | DestinationUpdate] + self, + items: DestinationWrite | DestinationUpdate | Sequence[DestinationWrite | DestinationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Destination | DestinationList: """`Update one or more destinations. `_ Args: items (DestinationWrite | DestinationUpdate | Sequence[DestinationWrite | DestinationUpdate]): Destination(s) to update. + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (DestinationWrite). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Destination | DestinationList: Updated destination(s) @@ -225,6 +241,7 @@ def update( list_cls=DestinationList, resource_cls=Destination, update_cls=DestinationUpdate, + mode=mode, headers={"cdf-version": "beta"}, ) diff --git a/cognite/client/_api/hosted_extractors/jobs.py b/cognite/client/_api/hosted_extractors/jobs.py index 22b1683ce4..3488d33aef 100644 --- a/cognite/client/_api/hosted_extractors/jobs.py +++ b/cognite/client/_api/hosted_extractors/jobs.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Sequence, overload +from typing import TYPE_CHECKING, Any, Literal, Sequence, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -194,16 +194,34 @@ def create(self, items: JobWrite | Sequence[JobWrite]) -> Job | JobList: ) @overload - def update(self, items: JobWrite | JobUpdate) -> Job: ... + def update( + self, + items: JobWrite | JobUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Job: ... @overload - def update(self, items: Sequence[JobWrite | JobUpdate]) -> JobList: ... + def update( + self, + items: Sequence[JobWrite | JobUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> JobList: ... - def update(self, items: JobWrite | JobUpdate | Sequence[JobWrite | JobUpdate]) -> Job | JobList: + def update( + self, + items: JobWrite | JobUpdate | Sequence[JobWrite | JobUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Job | JobList: """`Update one or more jobs. `_ Args: items (JobWrite | JobUpdate | Sequence[JobWrite | JobUpdate]): Job(s) to update. + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (JobWrite). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Job | JobList: Updated job(s) @@ -224,6 +242,7 @@ def update(self, items: JobWrite | JobUpdate | Sequence[JobWrite | JobUpdate]) - list_cls=JobList, resource_cls=Job, update_cls=JobUpdate, + mode=mode, headers={"cdf-version": "beta"}, ) diff --git a/cognite/client/_api/hosted_extractors/sources.py b/cognite/client/_api/hosted_extractors/sources.py index 066930127b..6e57deb351 100644 --- a/cognite/client/_api/hosted_extractors/sources.py +++ b/cognite/client/_api/hosted_extractors/sources.py @@ -186,16 +186,34 @@ def create(self, items: SourceWrite | Sequence[SourceWrite]) -> Source | SourceL ) @overload - def update(self, items: SourceWrite | SourceUpdate) -> Source: ... + def update( + self, + items: SourceWrite | SourceUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Source: ... @overload - def update(self, items: Sequence[SourceWrite | SourceUpdate]) -> SourceList: ... + def update( + self, + items: Sequence[SourceWrite | SourceUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> SourceList: ... - def update(self, items: SourceWrite | SourceUpdate | Sequence[SourceWrite | SourceUpdate]) -> Source | SourceList: + def update( + self, + items: SourceWrite | SourceUpdate | Sequence[SourceWrite | SourceUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Source | SourceList: """`Update one or more sources. `_ Args: items (SourceWrite | SourceUpdate | Sequence[SourceWrite | SourceUpdate]): Source(s) to update. + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (SourceWrite). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Source | SourceList: Updated source(s) @@ -216,6 +234,7 @@ def update(self, items: SourceWrite | SourceUpdate | Sequence[SourceWrite | Sour list_cls=SourceList, resource_cls=Source, # type: ignore[type-abstract] update_cls=SourceUpdate, + mode=mode, headers={"cdf-version": "beta"}, ) diff --git a/cognite/client/_api/relationships.py b/cognite/client/_api/relationships.py index 361f2272f0..aaec16622d 100644 --- a/cognite/client/_api/relationships.py +++ b/cognite/client/_api/relationships.py @@ -421,12 +421,19 @@ def update( | RelationshipWrite | RelationshipUpdate | Sequence[Relationship | RelationshipWrite | RelationshipUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Relationship | RelationshipList: """`Update one or more relationships `_ Currently, a full replacement of labels on a relationship is not supported (only partial add/remove updates). See the example below on how to perform partial labels update. Args: item (Relationship | RelationshipWrite | RelationshipUpdate | Sequence[Relationship | RelationshipWrite | RelationshipUpdate]): Relationship(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (Relationship or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Relationship | RelationshipList: Updated relationship(s) @@ -468,7 +475,7 @@ def update( >>> res = client.relationships.update(my_update) """ return self._update_multiple( - list_cls=RelationshipList, resource_cls=Relationship, update_cls=RelationshipUpdate, items=item + list_cls=RelationshipList, resource_cls=Relationship, update_cls=RelationshipUpdate, items=item, mode=mode ) @overload diff --git a/cognite/client/_api/sequences.py b/cognite/client/_api/sequences.py index bc954a80ce..e114b8d492 100644 --- a/cognite/client/_api/sequences.py +++ b/cognite/client/_api/sequences.py @@ -574,19 +574,34 @@ def delete( ) @overload - def update(self, item: Sequence | SequenceWrite | SequenceUpdate) -> Sequence: ... + def update( + self, + item: Sequence | SequenceWrite | SequenceUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Sequence: ... @overload - def update(self, item: typing.Sequence[Sequence | SequenceWrite | SequenceUpdate]) -> SequenceList: ... + def update( + self, + item: typing.Sequence[Sequence | SequenceWrite | SequenceUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> SequenceList: ... def update( self, item: Sequence | SequenceWrite | SequenceUpdate | typing.Sequence[Sequence | SequenceWrite | SequenceUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Sequence | SequenceList: """`Update one or more sequences. `_ Args: item (Sequence | SequenceWrite | SequenceUpdate | typing.Sequence[Sequence | SequenceWrite | SequenceUpdate]): Sequences to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (Sequence or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: Sequence | SequenceList: Updated sequences. @@ -666,7 +681,7 @@ def update( >>> res = client.sequences.update(my_update) """ return self._update_multiple( - list_cls=SequenceList, resource_cls=Sequence, update_cls=SequenceUpdate, items=item + list_cls=SequenceList, resource_cls=Sequence, update_cls=SequenceUpdate, items=item, mode=mode ) @overload diff --git a/cognite/client/_api/three_d.py b/cognite/client/_api/three_d.py index a97688578b..b437ccf8f9 100644 --- a/cognite/client/_api/three_d.py +++ b/cognite/client/_api/three_d.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, Sequence, overload +from typing import TYPE_CHECKING, Iterator, Literal, Sequence, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -193,19 +193,34 @@ def create( return self._create_multiple(list_cls=ThreeDModelList, resource_cls=ThreeDModel, items=items) @overload - def update(self, item: ThreeDModel | ThreeDModelUpdate) -> ThreeDModel: ... + def update( + self, + item: ThreeDModel | ThreeDModelUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> ThreeDModel: ... @overload - def update(self, item: Sequence[ThreeDModel | ThreeDModelUpdate]) -> ThreeDModelList: ... + def update( + self, + item: Sequence[ThreeDModel | ThreeDModelUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> ThreeDModelList: ... def update( self, item: ThreeDModel | ThreeDModelUpdate | Sequence[ThreeDModel | ThreeDModelUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> ThreeDModel | ThreeDModelList: """`Update 3d models. `_ Args: item (ThreeDModel | ThreeDModelUpdate | Sequence[ThreeDModel | ThreeDModelUpdate]): ThreeDModel(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (ThreeDModel or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: ThreeDModel | ThreeDModelList: Updated ThreeDModel(s) @@ -232,7 +247,11 @@ def update( # Note that we cannot use the ThreeDModelWrite to update as the write format of a 3D model # does not have ID or External ID, thus no identifier to know which model to update. return self._update_multiple( - list_cls=ThreeDModelList, resource_cls=ThreeDModel, update_cls=ThreeDModelUpdate, items=item + list_cls=ThreeDModelList, + resource_cls=ThreeDModel, + update_cls=ThreeDModelUpdate, + items=item, + mode=mode, ) def delete(self, id: int | Sequence[int]) -> None: @@ -395,12 +414,19 @@ def update( item: ThreeDModelRevision | ThreeDModelRevisionUpdate | Sequence[ThreeDModelRevision | ThreeDModelRevisionUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> ThreeDModelRevision | ThreeDModelRevisionList: """`Update 3d model revisions. `_ Args: model_id (int): Update the revision under the model with this id. item (ThreeDModelRevision | ThreeDModelRevisionUpdate | Sequence[ThreeDModelRevision | ThreeDModelRevisionUpdate]): ThreeDModelRevision(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (ThreeDModelRevision or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: ThreeDModelRevision | ThreeDModelRevisionList: Updated ThreeDModelRevision(s) @@ -429,6 +455,7 @@ def update( update_cls=ThreeDModelRevisionUpdate, resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, model_id), items=item, + mode=mode, ) def delete(self, model_id: int, id: int | Sequence[int]) -> None: diff --git a/cognite/client/_api/time_series.py b/cognite/client/_api/time_series.py index 35977c03fb..77bbc65fed 100644 --- a/cognite/client/_api/time_series.py +++ b/cognite/client/_api/time_series.py @@ -579,10 +579,18 @@ def delete( ) @overload - def update(self, item: Sequence[TimeSeries | TimeSeriesWrite | TimeSeriesUpdate]) -> TimeSeriesList: ... + def update( + self, + item: Sequence[TimeSeries | TimeSeriesWrite | TimeSeriesUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> TimeSeriesList: ... @overload - def update(self, item: TimeSeries | TimeSeriesWrite | TimeSeriesUpdate) -> TimeSeries: ... + def update( + self, + item: TimeSeries | TimeSeriesWrite | TimeSeriesUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> TimeSeries: ... def update( self, @@ -590,11 +598,18 @@ def update( | TimeSeriesWrite | TimeSeriesUpdate | Sequence[TimeSeries | TimeSeriesWrite | TimeSeriesUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> TimeSeries | TimeSeriesList: """`Update one or more time series. `_ Args: item (TimeSeries | TimeSeriesWrite | TimeSeriesUpdate | Sequence[TimeSeries | TimeSeriesWrite | TimeSeriesUpdate]): Time series to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update + object is given (TimeSeries or -Write). If you use 'replace_ignore_null', only the fields + you have set will be used to replace existing (default). Using 'replace' will additionally + clear all the fields that are not specified by you. Last option, 'patch', will update only + the fields you have set and for container-like fields such as metadata or labels, add the + values to the existing. For more details, see :ref:`appendix-update`. Returns: TimeSeries | TimeSeriesList: Updated time series. @@ -622,6 +637,7 @@ def update( resource_cls=TimeSeries, update_cls=TimeSeriesUpdate, items=item, + mode=mode, ) @overload diff --git a/cognite/client/_api/transformations/__init__.py b/cognite/client/_api/transformations/__init__.py index bc8ad5e3e8..f4f9f82a49 100644 --- a/cognite/client/_api/transformations/__init__.py +++ b/cognite/client/_api/transformations/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Sequence, overload +from typing import TYPE_CHECKING, Any, Literal, Sequence, overload from cognite.client._api.transformations.jobs import TransformationJobsAPI from cognite.client._api.transformations.notifications import TransformationNotificationsAPI @@ -415,11 +415,17 @@ def retrieve_multiple( ) @overload - def update(self, item: Transformation | TransformationWrite | TransformationUpdate) -> Transformation: ... + def update( + self, + item: Transformation | TransformationWrite | TransformationUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> Transformation: ... @overload def update( - self, item: Sequence[Transformation | TransformationWrite | TransformationUpdate] + self, + item: Sequence[Transformation | TransformationWrite | TransformationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> TransformationList: ... def update( @@ -428,11 +434,13 @@ def update( | TransformationWrite | TransformationUpdate | Sequence[Transformation | TransformationWrite | TransformationUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> Transformation | TransformationList: """`Update one or more transformations `_ Args: item (Transformation | TransformationWrite | TransformationUpdate | Sequence[Transformation | TransformationWrite | TransformationUpdate]): Transformation(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update object is given (Transformation or -Write). If you use 'replace_ignore_null', only the fields you have set will be used to replace existing (default). Using 'replace' will additionally clear all the fields that are not specified by you. Last option, 'patch', will update only the fields you have set and for container-like fields such as metadata or labels, add the values to the existing. For more details, see :ref:`appendix-update`. Returns: Transformation | TransformationList: Updated transformation(s) @@ -488,7 +496,11 @@ def update( ) return self._update_multiple( - list_cls=TransformationList, resource_cls=Transformation, update_cls=TransformationUpdate, items=item + list_cls=TransformationList, + resource_cls=Transformation, + update_cls=TransformationUpdate, + items=item, + mode=mode, ) def run( diff --git a/cognite/client/_api/transformations/schedules.py b/cognite/client/_api/transformations/schedules.py index bf78e8eccd..2f43f89bd9 100644 --- a/cognite/client/_api/transformations/schedules.py +++ b/cognite/client/_api/transformations/schedules.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterator -from typing import TYPE_CHECKING, Sequence, overload +from typing import TYPE_CHECKING, Literal, Sequence, overload from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -234,12 +234,16 @@ def delete( @overload def update( - self, item: TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate + self, + item: TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate, + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> TransformationSchedule: ... @overload def update( - self, item: Sequence[TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate] + self, + item: Sequence[TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> TransformationScheduleList: ... def update( @@ -248,11 +252,13 @@ def update( | TransformationScheduleWrite | TransformationScheduleUpdate | Sequence[TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", ) -> TransformationSchedule | TransformationScheduleList: """`Update one or more transformation schedules `_ Args: item (TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate | Sequence[TransformationSchedule | TransformationScheduleWrite | TransformationScheduleUpdate]): Transformation schedule(s) to update + mode (Literal["replace_ignore_null", "patch", "replace"]): How to update data when a non-update object is given (TransformationSchedule or -Write). If you use 'replace_ignore_null', only the fields you have set will be used to replace existing (default). Using 'replace' will additionally clear all the fields that are not specified by you. Last option, 'patch', will update only the fields you have set and for container-like fields such as metadata or labels, add the values to the existing. For more details, see :ref:`appendix-update`. Returns: TransformationSchedule | TransformationScheduleList: Updated transformation schedule(s) @@ -280,4 +286,5 @@ def update( resource_cls=TransformationSchedule, update_cls=TransformationScheduleUpdate, items=item, + mode=mode, ) diff --git a/cognite/client/_version.py b/cognite/client/_version.py index a392495486..31a9fd9317 100644 --- a/cognite/client/_version.py +++ b/cognite/client/_version.py @@ -1,4 +1,4 @@ from __future__ import annotations -__version__ = "7.61.1" +__version__ = "7.62.0" __api_subversion__ = "20230101" diff --git a/docs/source/appendix.rst b/docs/source/appendix.rst index 046948e00f..541d5694ed 100644 --- a/docs/source/appendix.rst +++ b/docs/source/appendix.rst @@ -1,5 +1,3 @@ -Appendix ---------- .. _appendix-upsert: @@ -14,12 +12,173 @@ notes apply: on whether the items exist from before or not. This means that if one of the calls fail, it is possible that some of the items have been updated/created while others have not been created/updated. -.. note:: - The mode parameter controls how the update is performed. If you set 'patch', the call will only update - the fields in the Item object that are not None. This means that if the items exists from before, the - fields that are not specified will not be changed. If you set 'replace', all the fields that are not - specified, i.e., set to None and support being set to null, will be nulled out. See the API - documentation for the update endpoint for more information. +.. _appendix-update: + +Update and Upsert Mode Parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The mode parameter controls how the update is performed. If you set 'patch', the call will only update +the fields in the Item object that are not None. This means that if the items exists from before, the +fields that are not specified will not be changed. The 'replace_ignore_null' works similarly, to +'patch', but instead of updating it will replace the fields with new values. There is not difference +between 'patch' and 'replace_ignore_null' for fields that only supports set. For example, `name` and +`description` on TimeSeries. However, for fields that supports `set` and `add/remove`, like `metadata`, +'patch` will add to the metadata, while 'replace_ignore_null' will replace the metadata with the new +metadata. If you set 'replace', all the fields that are not specified, i.e., set to None and +support being set to null, will be nulled out. + +Example **patch**: + +.. testsetup:: patch_update + + >>> getfixture("appendix_update_patch") # Fixture defined in conftest.py + +.. doctest:: patch_update + +.. code:: python + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import TimeSeriesWrite + >>> from pprint import pprint + >>> client = CogniteClient() + >>> + >>> new_ts = client.time_series.create( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... name="New TS", + ... metadata={"key": "value", "another": "one"} + ... ) + ... ) + >>> + >>> updated = client.time_series.update( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... description="Updated description", + ... metadata={"key": "new value", "brand": "new"} + ... ), + ... mode="patch" + ... ) + >>> pprint(updated.as_write().dump()) + {'description': 'Updated description', + 'externalId': 'new_ts', + 'metadata': {'another': 'one', 'brand': 'new', 'key': 'new value'}, + 'name': 'New TS'} + +Example **replace**: + +.. testsetup:: patch_replace + + >>> getfixture("appendix_update_replace") # Fixture defined in conftest.py + +.. doctest:: patch_replace + +.. code:: python + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import TimeSeriesWrite + >>> from pprint import pprint + >>> client = CogniteClient() + >>> + >>> new_ts = client.time_series.create( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... name="New TS", + ... metadata={"key": "value"} + ... ) + ... ) + >>> + >>> updated = client.time_series.update( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... description="Updated description", + ... metadata={"new": "entry"} + ... ), + ... mode="replace" + ... ) + >>> pprint(updated.as_write().dump()) + {'description': 'Updated description', + 'externalId': 'new_ts', + 'metadata': {'new': 'entry'}} + +**Note** that the `name` parameter was not specified in the update, and was therefore nulled out. + +Example **replace_ignore_null**: + +.. testsetup:: patch_replace_ignore_null + + >>> getfixture("appendix_update_replace_ignore_null") # Fixture defined in conftest.py + +.. doctest:: patch_replace_ignore_null + +.. code:: python + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import TimeSeriesWrite + >>> from pprint import pprint + >>> client = CogniteClient() + >>> + >>> new_ts = client.time_series.create( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... name="New TS", + ... metadata={"key": "value"} + ... ) + ... ) + >>> + >>> updated = client.time_series.update( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... description="Updated description", + ... metadata={"new": "entry"} + ... ), + ... mode="replace_ignore_null" + ... ) + >>> pprint(updated.as_write().dump()) + {'description': 'Updated description', + 'externalId': 'new_ts', + 'metadata': {'new': 'entry'}, + 'name': 'New TS'} + +**Note** that the `name` parameter was not specified in the update, and was therefore not changed, +same as in `patch` + +Example **replace_ignore_null** without `metadata`: + +.. testsetup:: patch_replace_ignore_null2 + + >>> getfixture("appendix_update_replace_ignore_null2") # Fixture defined in conftest.py + +.. doctest:: patch_replace_ignore_null2 + +.. code:: python + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import TimeSeriesWrite + >>> from pprint import pprint + >>> client = CogniteClient() + >>> + >>> new_ts = client.time_series.create( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... name="New TS", + ... metadata={"key": "value"} + ... ) + ... ) + >>> + >>> updated = client.time_series.update( + ... TimeSeriesWrite( + ... external_id="new_ts", + ... description="Updated description", + ... ), + ... mode="replace_ignore_null" + ... ) + >>> pprint(updated.as_write().dump()) + {'description': 'Updated description', + 'externalId': 'new_ts', + 'metadata': {'key': 'value'}, + 'name': 'New TS'} + +**Note** Since `metadata` was not specified in the update, it was not changed. .. _appendix-alpha-beta-features: diff --git a/docs/source/conftest.py b/docs/source/conftest.py index 8c3839c628..4bbdbada16 100644 --- a/docs/source/conftest.py +++ b/docs/source/conftest.py @@ -1,9 +1,15 @@ +from __future__ import annotations + import os from pathlib import Path +from typing import Any import pytest import yaml +from cognite.client.data_classes import TimeSeries +from cognite.client.testing import monkeypatch_cognite_client + # Files to exclude test directories or modules collect_ignore = ["conf.py"] @@ -21,8 +27,8 @@ def set_envs(monkeypatch): @pytest.fixture -def quickstart_client_config_file(monkeypatch): - data = { +def client_data() -> dict[str, Any]: + return { "client": { "project": "my-project", "client_name": "my-special-client", @@ -42,7 +48,57 @@ def quickstart_client_config_file(monkeypatch): }, } + +@pytest.fixture +def quickstart_client_config_file(monkeypatch, client_data): def read_text(*args, **kwargs): - return yaml.dump(data) + return yaml.dump(client_data) monkeypatch.setattr(Path, "read_text", read_text) + + +@pytest.fixture() +def appendix_update_patch() -> None: + with monkeypatch_cognite_client() as client: + client.time_series.update.return_value = TimeSeries( + external_id="new_ts", + name="New TS", + description="Updated description", + metadata={"another": "one", "brand": "new", "key": "new value"}, + ) + yield None + + +@pytest.fixture() +def appendix_update_replace() -> None: + with monkeypatch_cognite_client() as client: + client.time_series.update.return_value = TimeSeries( + external_id="new_ts", + description="Updated description", + metadata={"new": "entry"}, + ) + yield None + + +@pytest.fixture() +def appendix_update_replace_ignore_null() -> None: + with monkeypatch_cognite_client() as client: + client.time_series.update.return_value = TimeSeries( + external_id="new_ts", + name="New TS", + description="Updated description", + metadata={"new": "entry"}, + ) + yield None + + +@pytest.fixture() +def appendix_update_replace_ignore_null2() -> None: + with monkeypatch_cognite_client() as client: + client.time_series.update.return_value = TimeSeries( + external_id="new_ts", + name="New TS", + description="Updated description", + metadata={"key": "value"}, + ) + yield None diff --git a/pyproject.toml b/pyproject.toml index 4ac2050340..6e43cfb268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "cognite-sdk" -version = "7.61.1" +version = "7.62.0" description = "Cognite Python SDK" readme = "README.md" documentation = "https://cognite-sdk-python.readthedocs-hosted.com"