|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | from collections.abc import Sequence |
| 5 | +from functools import cached_property |
4 | 6 | from pathlib import Path |
5 | 7 | from typing import TYPE_CHECKING, Any, NoReturn, overload |
6 | 8 |
|
7 | 9 | from cognite.client._api_client import APIClient |
8 | 10 | from cognite.client._constants import DEFAULT_LIMIT_READ |
| 11 | +from cognite.client.config import global_config |
9 | 12 | from cognite.client.data_classes.data_modeling.cdm.v1 import CogniteFile |
10 | 13 | from cognite.client.data_classes.data_modeling.ids import NodeId, ViewId |
11 | | -from cognite.client.data_classes.data_modeling.instances import InstanceSort, Node, NodeList |
| 14 | +from cognite.client.data_classes.data_modeling.instances import InstanceSort, Node, NodeApply, NodeList |
12 | 15 | from cognite.client.data_classes.data_modeling.views import View |
| 16 | +from cognite.client.data_classes.files import FileMetadata |
13 | 17 | from cognite.client.data_classes.filters import Filter |
| 18 | +from cognite.client.exceptions import CogniteFileUploadError, CogniteNotFoundError |
| 19 | +from cognite.client.utils._retry import Backoff |
14 | 20 | from cognite.client.utils.useful_types import SequenceNotStr |
15 | 21 |
|
16 | 22 | if TYPE_CHECKING: |
| 23 | + from collections.abc import Awaitable, Callable |
| 24 | + from typing import BinaryIO |
| 25 | + |
17 | 26 | from cognite.client import AsyncCogniteClient |
| 27 | + from cognite.client._api.data_modeling.instances import InstancesAPI |
18 | 28 | from cognite.client.config import ClientConfig |
19 | 29 |
|
20 | 30 | COGNITE_FILE_VIEW_ID = CogniteFile.get_source() |
@@ -45,6 +55,10 @@ def __init__(self, config: ClientConfig, api_version: str | None, cognite_client |
45 | 55 | super().__init__(config, api_version, cognite_client) |
46 | 56 | self._files_api = cognite_client.files |
47 | 57 |
|
| 58 | + @cached_property |
| 59 | + def _instances_api(self) -> InstancesAPI: |
| 60 | + return self._cognite_client.data_modeling.instances |
| 61 | + |
48 | 62 | async def retrieve_download_urls( |
49 | 63 | self, |
50 | 64 | node_ids: NodeId | tuple[str, str] | Sequence[NodeId | tuple[str, str]], |
@@ -160,17 +174,186 @@ async def download_bytes(self, node_id: NodeId | tuple[str, str]) -> bytes: |
160 | 174 | """ |
161 | 175 | return await self._files_api.download_bytes(instance_id=node_id) |
162 | 176 |
|
163 | | - async def upload(self, *args: Any, **kwargs: Any) -> NoReturn: |
164 | | - raise NotImplementedError("This method is not implemented yet!") |
| 177 | + async def upload(self, path: Path, node: NodeApply) -> None: |
| 178 | + """`Create a file node and upload content in one step. <https://api-docs.cognite.com/20230101/tag/Files/operation/getUploadLink>`_ |
165 | 179 |
|
166 | | - async def upload_bytes(self, *args: Any, **kwargs: Any) -> NoReturn: |
167 | | - raise NotImplementedError("This method is not implemented yet!") |
| 180 | + The node is created (or updated) via ``instances.apply``, then the file content is uploaded. |
168 | 181 |
|
169 | | - async def upload_content(self, *args: Any, **kwargs: Any) -> NoReturn: |
170 | | - raise NotImplementedError("This method is not implemented yet!") |
| 182 | + Args: |
| 183 | + path (Path): Path to the file to upload. |
| 184 | + node (NodeApply): The file node to apply before uploading. |
171 | 185 |
|
172 | | - async def upload_content_bytes(self, *args: Any, **kwargs: Any) -> NoReturn: |
173 | | - raise NotImplementedError("This method is not implemented yet!") |
| 186 | + Examples: |
| 187 | +
|
| 188 | + Create a file node and upload content: |
| 189 | +
|
| 190 | + >>> from cognite.client import CogniteClient |
| 191 | + >>> from cognite.client.data_classes.data_modeling.cdm.v1 import CogniteFileApply |
| 192 | + >>> client = CogniteClient() |
| 193 | + >>> file_name = "Quarterly-Report.pdf" |
| 194 | + >>> client.data_modeling.files.upload( |
| 195 | + ... Path(file_name), |
| 196 | + ... CogniteFileApply( |
| 197 | + ... space="my-space", |
| 198 | + ... external_id="my-file", |
| 199 | + ... name=file_name, |
| 200 | + ... mime_type="application/pdf", |
| 201 | + ... ), |
| 202 | + ... ) |
| 203 | + """ |
| 204 | + await self._instances_api.apply(nodes=node) |
| 205 | + node_id = node.as_id() |
| 206 | + await self._upload_to_newly_created_file_node( |
| 207 | + node_id, upload_fn=lambda: self._files_api.upload_content(path=path, instance_id=node_id) |
| 208 | + ) |
| 209 | + |
| 210 | + async def upload_bytes(self, content: str | bytes | BinaryIO, node: NodeApply) -> None: |
| 211 | + """Create a file node and upload in-memory content in one step. |
| 212 | +
|
| 213 | + The node is created (or updated) via ``instances.apply``, then the content is uploaded. |
| 214 | +
|
| 215 | + Args: |
| 216 | + content (str | bytes | BinaryIO): The content to upload. |
| 217 | + node (NodeApply): The file node to apply before uploading. |
| 218 | +
|
| 219 | + Examples: |
| 220 | +
|
| 221 | + Create a file node and upload bytes: |
| 222 | +
|
| 223 | + >>> from cognite.client import CogniteClient |
| 224 | + >>> from cognite.client.data_classes.data_modeling.cdm.v1 import CogniteFileApply |
| 225 | + >>> client = CogniteClient() |
| 226 | + >>> client.data_modeling.files.upload_bytes( |
| 227 | + ... b"some important notes", |
| 228 | + ... CogniteFileApply( |
| 229 | + ... space="my-space", |
| 230 | + ... external_id="my-file", |
| 231 | + ... name="notes.txt", |
| 232 | + ... mime_type="text/plain", |
| 233 | + ... ), |
| 234 | + ... ) |
| 235 | + """ |
| 236 | + await self._instances_api.apply(nodes=node) |
| 237 | + node_id = node.as_id() |
| 238 | + await self._upload_to_newly_created_file_node( |
| 239 | + node_id, upload_fn=lambda: self._files_api.upload_content_bytes(content=content, instance_id=node_id) |
| 240 | + ) |
| 241 | + |
| 242 | + async def _upload_to_newly_created_file_node( |
| 243 | + self, node_id: NodeId, upload_fn: Callable[[], Awaitable[FileMetadata]] |
| 244 | + ) -> None: |
| 245 | + try: |
| 246 | + await upload_fn() |
| 247 | + return # we do not want to return legacy FileMetadata |
| 248 | + except CogniteNotFoundError as err: |
| 249 | + # If a newly created node is not found, we first need to verify that the node is actually a file node. |
| 250 | + # The retrieve endpoint is immediately consistent, so we check: |
| 251 | + if await self.retrieve(node_id, source=COGNITE_FILE_VIEW_ID) is None: |
| 252 | + raise CogniteFileUploadError( |
| 253 | + f"The file upload failed because the target {node_id=} is not a file node. " |
| 254 | + "Make sure to write through CogniteFile or an extension of it.", |
| 255 | + code=err.code, |
| 256 | + ) from err |
| 257 | + |
| 258 | + # We now know that the newly created node -is- a file node, we are just experiencing propagation delays to the |
| 259 | + # backend file service. We should retry with backoff settings (set by the user): |
| 260 | + await self._upload_with_retry(node_id, upload_fn, err) |
| 261 | + |
| 262 | + async def _upload_with_retry( |
| 263 | + self, |
| 264 | + node_id: NodeId, |
| 265 | + upload_fn: Callable[[], Awaitable[FileMetadata]], |
| 266 | + latest_error: CogniteNotFoundError, |
| 267 | + ) -> None: |
| 268 | + backoff = Backoff(max_wait=global_config.max_retry_backoff) |
| 269 | + for _ in range(global_config.max_retries): |
| 270 | + await asyncio.sleep(next(backoff)) # we sleep immediately because we have already tried uploading |
| 271 | + try: |
| 272 | + await upload_fn() |
| 273 | + return |
| 274 | + except CogniteNotFoundError as err: |
| 275 | + latest_error = err |
| 276 | + |
| 277 | + total_attempts = global_config.max_retries + 1 |
| 278 | + raise CogniteFileUploadError( |
| 279 | + f"The file upload failed to {node_id=} after {total_attempts} attempt(s): " |
| 280 | + "backend file propagation is taking longer than expected. " |
| 281 | + "Ensure no one has deleted the file node in the meantime, then try again shortly.", |
| 282 | + code=latest_error.code, |
| 283 | + ) from latest_error |
| 284 | + |
| 285 | + async def upload_content(self, path: Path, node_id: NodeId | tuple[str, str]) -> None: |
| 286 | + """`Upload content to an existing file node by instance ID. <https://api-docs.cognite.com/20230101/tag/Files/operation/getUploadLink>`_ |
| 287 | +
|
| 288 | + Args: |
| 289 | + path (Path): Path to the file to upload. |
| 290 | + node_id (NodeId | tuple[str, str]): Instance ID of the file node. |
| 291 | +
|
| 292 | + Examples: |
| 293 | +
|
| 294 | + Upload file content by instance ID: |
| 295 | +
|
| 296 | + >>> from pathlib import Path |
| 297 | + >>> from cognite.client import CogniteClient |
| 298 | + >>> from cognite.client.data_classes.data_modeling import NodeId |
| 299 | + >>> client = CogniteClient() |
| 300 | + >>> client.data_modeling.files.upload_content( |
| 301 | + ... Path("/path/to/file.txt"), NodeId("my-space", "my-file") |
| 302 | + ... ) |
| 303 | + """ |
| 304 | + node_id = NodeId.load(node_id) |
| 305 | + await self._upload_to_existing_node( |
| 306 | + node_id, upload_fn=lambda: self._files_api.upload_content(path=path, instance_id=node_id) |
| 307 | + ) |
| 308 | + |
| 309 | + async def _upload_to_existing_node(self, node_id: NodeId, upload_fn: Callable[[], Awaitable[FileMetadata]]) -> None: |
| 310 | + try: |
| 311 | + await upload_fn() |
| 312 | + return # we do not want to return legacy FileMetadata |
| 313 | + except CogniteNotFoundError as err: |
| 314 | + # We did not create the node before upload, so we don't know if the node even exists. We first |
| 315 | + # need to verify that the node is actually a file node, so we use the retrieve endpoint which |
| 316 | + # is immediately consistent to check: |
| 317 | + if await self.retrieve(node_id, source=COGNITE_FILE_VIEW_ID) is None: |
| 318 | + if await self._instances_api.retrieve_nodes(nodes=node_id, sources=None): |
| 319 | + err_msg = ( |
| 320 | + f"The file upload failed because the target {node_id=} exists but is not a file node. " |
| 321 | + "Make sure to write through CogniteFile or an extension of it." |
| 322 | + ) |
| 323 | + else: |
| 324 | + err_msg = f"The file upload failed because the target {node_id=} does not exist." |
| 325 | + raise CogniteFileUploadError(err_msg, code=err.code) from err |
| 326 | + |
| 327 | + # We now know that the existing node -is- a file node, we are just experiencing propagation delays to the |
| 328 | + # backend file service. We should retry with backoff settings (set by the user): |
| 329 | + await self._upload_with_retry(node_id, upload_fn, err) |
| 330 | + |
| 331 | + async def upload_content_bytes( |
| 332 | + self, |
| 333 | + content: str | bytes | BinaryIO, |
| 334 | + node_id: NodeId | tuple[str, str], |
| 335 | + ) -> None: |
| 336 | + """Upload bytes or string content to an existing file node by instance ID. |
| 337 | +
|
| 338 | + Args: |
| 339 | + content (str | bytes | BinaryIO): The content to upload. |
| 340 | + node_id (NodeId | tuple[str, str]): Instance ID of the file node. |
| 341 | +
|
| 342 | + Examples: |
| 343 | +
|
| 344 | + Upload bytes to an existing file node by instance ID: |
| 345 | +
|
| 346 | + >>> from cognite.client import CogniteClient |
| 347 | + >>> from cognite.client.data_classes.data_modeling import NodeId |
| 348 | + >>> client = CogniteClient() |
| 349 | + >>> client.data_modeling.files.upload_content_bytes( |
| 350 | + ... b"some content", NodeId("my-space", "my-file") |
| 351 | + ... ) |
| 352 | + """ |
| 353 | + node_id = NodeId.load(node_id) |
| 354 | + await self._upload_to_existing_node( |
| 355 | + node_id, upload_fn=lambda: self._files_api.upload_content_bytes(content=content, instance_id=node_id) |
| 356 | + ) |
174 | 357 |
|
175 | 358 | async def __call__(self) -> NoReturn: |
176 | 359 | raise NotImplementedError("This method is not implemented yet!") |
@@ -237,7 +420,7 @@ async def retrieve( |
237 | 420 | ... ) |
238 | 421 | """ |
239 | 422 | sources, strip = _resolve_source(source) |
240 | | - result = await self._cognite_client.data_modeling.instances.retrieve_nodes(nodes=node_ids, sources=sources) |
| 423 | + result = await self._instances_api.retrieve_nodes(nodes=node_ids, sources=sources) |
241 | 424 | if strip and result: |
242 | 425 | for node in [result] if isinstance(result, Node) else result: |
243 | 426 | node.drop_source(COGNITE_FILE_VIEW_ID) |
@@ -291,7 +474,7 @@ async def list( |
291 | 474 | ... ) |
292 | 475 | """ |
293 | 476 | sources, strip = _resolve_source(source) |
294 | | - results = await self._cognite_client.data_modeling.instances.list( |
| 477 | + results = await self._instances_api.list( |
295 | 478 | instance_type="node", |
296 | 479 | sources=sources, |
297 | 480 | space=space, |
|
0 commit comments