cognite-neat 0.123.32__py3-none-any.whl → 0.123.34__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of cognite-neat might be problematic. Click here for more details.
- cognite/neat/__init__.py +2 -2
- cognite/neat/_data_model/_constants.py +3 -0
- cognite/neat/_data_model/_identifiers.py +61 -0
- cognite/neat/_data_model/models/dms/_space.py +1 -1
- cognite/neat/_data_model/models/entities/__init__.py +46 -0
- cognite/neat/_data_model/models/entities/_base.py +100 -0
- cognite/neat/_data_model/models/entities/_data_types.py +144 -0
- cognite/neat/{data_model → _data_model}/models/entities/_identifiers.py +1 -1
- cognite/neat/_data_model/models/entities/_parser.py +194 -0
- cognite/neat/_utils/auxiliary.py +7 -0
- cognite/neat/_utils/http_client/__init__.py +45 -0
- cognite/neat/_utils/http_client/_client.py +284 -0
- cognite/neat/_utils/http_client/_config.py +19 -0
- cognite/neat/_utils/http_client/_data_classes.py +389 -0
- cognite/neat/_utils/http_client/_tracker.py +31 -0
- cognite/neat/_utils/useful_types.py +7 -0
- cognite/neat/_version.py +1 -1
- cognite/neat/{core → v0/core}/_client/_api/data_modeling_loaders.py +6 -6
- cognite/neat/{core → v0/core}/_client/_api/neat_instances.py +5 -5
- cognite/neat/{core → v0/core}/_client/_api/schema.py +5 -5
- cognite/neat/{core → v0/core}/_client/_api/statistics.py +3 -3
- cognite/neat/{core → v0/core}/_client/_api_client.py +1 -1
- cognite/neat/{core → v0/core}/_client/data_classes/schema.py +4 -4
- cognite/neat/{core → v0/core}/_client/testing.py +1 -1
- cognite/neat/{core → v0/core}/_constants.py +3 -3
- cognite/neat/{core → v0/core}/_data_model/_shared.py +4 -4
- cognite/neat/{core → v0/core}/_data_model/analysis/_base.py +8 -8
- cognite/neat/{core → v0/core}/_data_model/exporters/_base.py +7 -7
- cognite/neat/{core → v0/core}/_data_model/exporters/_data_model2dms.py +9 -9
- cognite/neat/{core → v0/core}/_data_model/exporters/_data_model2excel.py +9 -9
- cognite/neat/{core → v0/core}/_data_model/exporters/_data_model2instance_template.py +4 -4
- cognite/neat/{core → v0/core}/_data_model/exporters/_data_model2ontology.py +9 -9
- cognite/neat/{core → v0/core}/_data_model/exporters/_data_model2yaml.py +1 -1
- cognite/neat/{core → v0/core}/_data_model/importers/_base.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/importers/_base_file_reader.py +2 -2
- cognite/neat/{core → v0/core}/_data_model/importers/_dict2data_model.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/importers/_dms2data_model.py +12 -12
- cognite/neat/{core → v0/core}/_data_model/importers/_graph2data_model.py +12 -12
- cognite/neat/{core → v0/core}/_data_model/importers/_rdf/_base.py +12 -12
- cognite/neat/{core → v0/core}/_data_model/importers/_rdf/_inference2rdata_model.py +14 -14
- cognite/neat/{core → v0/core}/_data_model/importers/_rdf/_owl2data_model.py +2 -2
- cognite/neat/{core → v0/core}/_data_model/importers/_rdf/_shared.py +7 -7
- cognite/neat/{core → v0/core}/_data_model/importers/_spreadsheet2data_model.py +8 -8
- cognite/neat/{core → v0/core}/_data_model/models/__init__.py +3 -3
- cognite/neat/{core → v0/core}/_data_model/models/_base_verified.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/models/_import_contexts.py +1 -1
- cognite/neat/{core → v0/core}/_data_model/models/_types.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/models/conceptual/_unverified.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/models/conceptual/_validation.py +12 -12
- cognite/neat/{core → v0/core}/_data_model/models/conceptual/_verified.py +9 -9
- cognite/neat/{core → v0/core}/_data_model/models/data_types.py +3 -3
- cognite/neat/{core → v0/core}/_data_model/models/entities/_loaders.py +2 -2
- cognite/neat/{core → v0/core}/_data_model/models/entities/_multi_value.py +2 -2
- cognite/neat/{core → v0/core}/_data_model/models/entities/_restrictions.py +6 -6
- cognite/neat/{core → v0/core}/_data_model/models/entities/_single_value.py +3 -3
- cognite/neat/{core → v0/core}/_data_model/models/mapping/_classic2core.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/models/physical/__init__.py +1 -1
- cognite/neat/{core → v0/core}/_data_model/models/physical/_exporter.py +8 -8
- cognite/neat/{core → v0/core}/_data_model/models/physical/_unverified.py +8 -8
- cognite/neat/{core → v0/core}/_data_model/models/physical/_validation.py +16 -16
- cognite/neat/{core → v0/core}/_data_model/models/physical/_verified.py +10 -10
- cognite/neat/{core → v0/core}/_data_model/transformers/_base.py +4 -4
- cognite/neat/{core → v0/core}/_data_model/transformers/_converters.py +25 -25
- cognite/neat/{core → v0/core}/_data_model/transformers/_mapping.py +7 -7
- cognite/neat/{core → v0/core}/_data_model/transformers/_union_conceptual.py +5 -5
- cognite/neat/{core → v0/core}/_data_model/transformers/_verification.py +7 -7
- cognite/neat/{core → v0/core}/_instances/_tracking/base.py +1 -1
- cognite/neat/{core → v0/core}/_instances/_tracking/log.py +1 -1
- cognite/neat/{core → v0/core}/_instances/extractors/__init__.py +1 -1
- cognite/neat/{core → v0/core}/_instances/extractors/_base.py +6 -6
- cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_base.py +7 -7
- cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_classic.py +12 -12
- cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_relationships.py +3 -3
- cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_sequences.py +2 -2
- cognite/neat/{core → v0/core}/_instances/extractors/_dict.py +2 -2
- cognite/neat/{core → v0/core}/_instances/extractors/_dms.py +6 -6
- cognite/neat/{core → v0/core}/_instances/extractors/_dms_graph.py +11 -11
- cognite/neat/{core → v0/core}/_instances/extractors/_mock_graph_generator.py +10 -10
- cognite/neat/{core → v0/core}/_instances/extractors/_raw.py +3 -3
- cognite/neat/{core → v0/core}/_instances/extractors/_rdf_file.py +7 -7
- cognite/neat/{core → v0/core}/_instances/loaders/_base.py +5 -5
- cognite/neat/{core → v0/core}/_instances/loaders/_rdf2dms.py +17 -17
- cognite/neat/{core → v0/core}/_instances/loaders/_rdf_to_instance_space.py +11 -11
- cognite/neat/{core → v0/core}/_instances/queries/_select.py +3 -3
- cognite/neat/{core → v0/core}/_instances/queries/_update.py +1 -1
- cognite/neat/{core → v0/core}/_instances/transformers/_base.py +4 -4
- cognite/neat/{core → v0/core}/_instances/transformers/_classic_cdf.py +6 -6
- cognite/neat/{core → v0/core}/_instances/transformers/_prune_graph.py +4 -4
- cognite/neat/{core → v0/core}/_instances/transformers/_rdfpath.py +1 -1
- cognite/neat/{core → v0/core}/_instances/transformers/_value_type.py +4 -4
- cognite/neat/{core → v0/core}/_issues/_base.py +5 -5
- cognite/neat/{core → v0/core}/_issues/_contextmanagers.py +1 -1
- cognite/neat/{core → v0/core}/_issues/_factory.py +3 -3
- cognite/neat/{core → v0/core}/_issues/errors/__init__.py +1 -1
- cognite/neat/{core → v0/core}/_issues/errors/_external.py +1 -1
- cognite/neat/{core → v0/core}/_issues/errors/_general.py +1 -1
- cognite/neat/{core → v0/core}/_issues/errors/_properties.py +1 -1
- cognite/neat/{core → v0/core}/_issues/errors/_resources.py +2 -2
- cognite/neat/{core → v0/core}/_issues/errors/_wrapper.py +2 -2
- cognite/neat/{core → v0/core}/_issues/warnings/__init__.py +1 -1
- cognite/neat/{core → v0/core}/_issues/warnings/_external.py +1 -1
- cognite/neat/{core → v0/core}/_issues/warnings/_general.py +1 -1
- cognite/neat/{core → v0/core}/_issues/warnings/_models.py +2 -2
- cognite/neat/{core → v0/core}/_issues/warnings/_properties.py +2 -2
- cognite/neat/{core → v0/core}/_issues/warnings/_resources.py +1 -1
- cognite/neat/{core → v0/core}/_issues/warnings/user_modeling.py +1 -1
- cognite/neat/{core → v0/core}/_store/_data_model.py +12 -12
- cognite/neat/{core → v0/core}/_store/_instance.py +10 -10
- cognite/neat/{core → v0/core}/_store/_provenance.py +3 -3
- cognite/neat/{core → v0/core}/_store/exceptions.py +4 -4
- cognite/neat/{core → v0/core}/_utils/auth.py +1 -1
- cognite/neat/{core → v0/core}/_utils/auxiliary.py +1 -1
- cognite/neat/{core → v0/core}/_utils/collection_.py +2 -2
- cognite/neat/{core → v0/core}/_utils/graph_transformations_report.py +1 -1
- cognite/neat/{core → v0/core}/_utils/rdf_.py +1 -1
- cognite/neat/{core → v0/core}/_utils/reader/_base.py +1 -1
- cognite/neat/{core → v0/core}/_utils/spreadsheet.py +1 -1
- cognite/neat/{core → v0/core}/_utils/text.py +1 -1
- cognite/neat/{core → v0/core}/_utils/upload.py +3 -3
- cognite/neat/{plugins → v0/plugins}/_issues.py +1 -1
- cognite/neat/{plugins → v0/plugins}/_manager.py +2 -2
- cognite/neat/{plugins → v0/plugins}/data_model/importers/_base.py +1 -1
- cognite/neat/{session → v0/session}/_base.py +10 -10
- cognite/neat/{session → v0/session}/_collector.py +1 -1
- cognite/neat/{session → v0/session}/_drop.py +3 -3
- cognite/neat/{session → v0/session}/_explore.py +2 -2
- cognite/neat/{session → v0/session}/_fix.py +2 -2
- cognite/neat/{session → v0/session}/_inspect.py +3 -3
- cognite/neat/{session → v0/session}/_mapping.py +3 -3
- cognite/neat/{session → v0/session}/_plugin.py +5 -5
- cognite/neat/{session → v0/session}/_prepare.py +8 -8
- cognite/neat/{session → v0/session}/_read.py +17 -17
- cognite/neat/{session → v0/session}/_set.py +8 -8
- cognite/neat/{session → v0/session}/_show.py +5 -5
- cognite/neat/{session → v0/session}/_state.py +10 -10
- cognite/neat/{session → v0/session}/_subset.py +4 -4
- cognite/neat/{session → v0/session}/_template.py +11 -11
- cognite/neat/{session → v0/session}/_to.py +12 -12
- cognite/neat/{session → v0/session}/_wizard.py +1 -1
- cognite/neat/{session → v0/session}/engine/_load.py +1 -1
- cognite/neat/{session → v0/session}/exceptions.py +5 -5
- {cognite_neat-0.123.32.dist-info → cognite_neat-0.123.34.dist-info}/METADATA +2 -1
- cognite_neat-0.123.34.dist-info/RECORD +219 -0
- cognite/neat/data_model/models/entities/__init__.py +0 -9
- cognite_neat-0.123.32.dist-info/RECORD +0 -209
- /cognite/neat/{data_model → _data_model}/models/entities/_constants.py +0 -0
- /cognite/neat/{core → v0}/__init__.py +0 -0
- /cognite/neat/{core/_client/_api → v0/core}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_client/__init__.py +0 -0
- /cognite/neat/{core/_client/data_classes → v0/core/_client/_api}/__init__.py +0 -0
- /cognite/neat/{core/_data_model → v0/core/_client/data_classes}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_client/data_classes/data_modeling.py +0 -0
- /cognite/neat/{core → v0/core}/_client/data_classes/neat_sequence.py +0 -0
- /cognite/neat/{core → v0/core}/_client/data_classes/statistics.py +0 -0
- /cognite/neat/{core → v0/core}/_config.py +0 -0
- /cognite/neat/{core/_instances → v0/core/_data_model}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/_constants.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/analysis/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/catalog/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/catalog/classic_model.xlsx +0 -0
- /cognite/neat/{core → v0/core}/_data_model/catalog/conceptual-imf-data-model.xlsx +0 -0
- /cognite/neat/{core → v0/core}/_data_model/catalog/hello_world_pump.xlsx +0 -0
- /cognite/neat/{core → v0/core}/_data_model/exporters/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/importers/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/importers/_rdf/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/_base_unverified.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/conceptual/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/entities/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/entities/_constants.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/entities/_types.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/entities/_wrapped.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/mapping/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_data_model/models/mapping/_classic2core.yaml +0 -0
- /cognite/neat/{core → v0/core}/_data_model/transformers/__init__.py +0 -0
- /cognite/neat/{core/_instances/extractors/_classic_cdf → v0/core/_instances}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/_shared.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/_tracking/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/examples/Knowledge-Graph-Nordic44-dirty.xml +0 -0
- /cognite/neat/{core → v0/core}/_instances/examples/Knowledge-Graph-Nordic44.xml +0 -0
- /cognite/neat/{core → v0/core}/_instances/examples/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/examples/skos-capturing-sheet-wind-topics.xlsx +0 -0
- /cognite/neat/{core/_utils → v0/core/_instances/extractors/_classic_cdf}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_assets.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_data_sets.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_events.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_files.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_labels.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/extractors/_classic_cdf/_timeseries.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/loaders/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/queries/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/queries/_base.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/queries/_queries.py +0 -0
- /cognite/neat/{core → v0/core}/_instances/transformers/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_issues/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_issues/formatters.py +0 -0
- /cognite/neat/{core → v0/core}/_shared.py +0 -0
- /cognite/neat/{core → v0/core}/_store/__init__.py +0 -0
- /cognite/neat/{data_model → v0/core/_utils}/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_utils/io_.py +0 -0
- /cognite/neat/{core → v0/core}/_utils/reader/__init__.py +0 -0
- /cognite/neat/{core → v0/core}/_utils/tarjan.py +0 -0
- /cognite/neat/{core → v0/core}/_utils/time_.py +0 -0
- /cognite/neat/{core → v0/core}/_utils/xml_.py +0 -0
- /cognite/neat/{plugins → v0/plugins}/__init__.py +0 -0
- /cognite/neat/{plugins → v0/plugins}/data_model/__init__.py +0 -0
- /cognite/neat/{plugins → v0/plugins}/data_model/importers/__init__.py +0 -0
- /cognite/neat/{session → v0/session}/__init__.py +0 -0
- /cognite/neat/{session → v0/session}/_experimental.py +0 -0
- /cognite/neat/{session → v0/session}/_state/README.md +0 -0
- /cognite/neat/{session → v0/session}/engine/__init__.py +0 -0
- /cognite/neat/{session → v0/session}/engine/_import.py +0 -0
- /cognite/neat/{session → v0/session}/engine/_interface.py +0 -0
- {cognite_neat-0.123.32.dist-info → cognite_neat-0.123.34.dist-info}/WHEEL +0 -0
- {cognite_neat-0.123.32.dist-info → cognite_neat-0.123.34.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import random
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import MutableMapping, Sequence, Set
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from cognite.client import ClientConfig, global_config
|
|
11
|
+
from cognite.client.utils import _json
|
|
12
|
+
|
|
13
|
+
from cognite.neat._utils.auxiliary import get_current_neat_version
|
|
14
|
+
from cognite.neat._utils.http_client._config import get_user_agent
|
|
15
|
+
from cognite.neat._utils.http_client._data_classes import (
|
|
16
|
+
BodyRequest,
|
|
17
|
+
FailedRequestMessage,
|
|
18
|
+
HTTPMessage,
|
|
19
|
+
ItemsRequest,
|
|
20
|
+
ParamRequest,
|
|
21
|
+
RequestMessage,
|
|
22
|
+
ResponseMessage,
|
|
23
|
+
)
|
|
24
|
+
from cognite.neat._utils.useful_types import JsonVal
|
|
25
|
+
|
|
26
|
+
if sys.version_info >= (3, 11):
|
|
27
|
+
from typing import Self
|
|
28
|
+
else:
|
|
29
|
+
from typing_extensions import Self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HTTPClient:
|
|
33
|
+
"""An HTTP client.
|
|
34
|
+
|
|
35
|
+
This class handles rate limiting, retries, and error handling for HTTP requests.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config (ClientConfig): Configuration for the client.
|
|
39
|
+
pool_connections (int): The number of connection pools to cache. Default is 10.
|
|
40
|
+
pool_maxsize (int): The maximum number of connections to save in the pool. Default
|
|
41
|
+
is 20.
|
|
42
|
+
max_retries (int): The maximum number of retries for a request. Default is 10.
|
|
43
|
+
retry_status_codes (frozenset[int]): HTTP status codes that should trigger a retry.
|
|
44
|
+
Default is {408, 429, 502, 503, 504}.
|
|
45
|
+
split_items_status_codes (frozenset[int]): In the case of ItemRequest with multiple
|
|
46
|
+
items, these status codes will trigger splitting the request into smaller batches.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config: ClientConfig,
|
|
53
|
+
max_retries: int = 10,
|
|
54
|
+
pool_connections: int = 10,
|
|
55
|
+
pool_maxsize: int = 20,
|
|
56
|
+
retry_status_codes: Set[int] = frozenset({429, 502, 503, 504}),
|
|
57
|
+
split_items_status_codes: Set[int] = frozenset({400, 408, 409, 422, 502, 503, 504}),
|
|
58
|
+
):
|
|
59
|
+
self.config = config
|
|
60
|
+
self._max_retries = max_retries
|
|
61
|
+
self._pool_connections = pool_connections
|
|
62
|
+
self._pool_maxsize = pool_maxsize
|
|
63
|
+
self._retry_status_codes = retry_status_codes
|
|
64
|
+
self._split_items_status_codes = split_items_status_codes
|
|
65
|
+
|
|
66
|
+
# Thread-safe session for connection pooling
|
|
67
|
+
self.session = self._create_thread_safe_session()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> Self:
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(
|
|
73
|
+
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: object | None
|
|
74
|
+
) -> Literal[False]:
|
|
75
|
+
"""Close the session when exiting the context."""
|
|
76
|
+
self.session.close()
|
|
77
|
+
return False # Do not suppress exceptions
|
|
78
|
+
|
|
79
|
+
def request(self, message: RequestMessage) -> Sequence[HTTPMessage]:
|
|
80
|
+
"""Send an HTTP request and return the response.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message (RequestMessage): The request message to send.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Sequence[HTTPMessage]: The response message(s). This can also
|
|
87
|
+
include RequestMessage(s) to be retried.
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(message, ItemsRequest) and message.tracker and message.tracker.limit_reached():
|
|
90
|
+
error_msg = (
|
|
91
|
+
f"Aborting further splitting of requests after {message.tracker.failed_split_count} failed attempts."
|
|
92
|
+
)
|
|
93
|
+
return message.create_failed_request(error_msg)
|
|
94
|
+
try:
|
|
95
|
+
response = self._make_request(message)
|
|
96
|
+
results = self._handle_response(response, message)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
results = self._handle_error(e, message)
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
def request_with_retries(self, message: RequestMessage) -> Sequence[ResponseMessage | FailedRequestMessage]:
|
|
102
|
+
"""Send an HTTP request and handle retries.
|
|
103
|
+
|
|
104
|
+
This method will keep retrying the request until it either succeeds or
|
|
105
|
+
exhausts the maximum number of retries.
|
|
106
|
+
|
|
107
|
+
Note this method will use the current thread to process all request, thus
|
|
108
|
+
it is blocking.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
message (RequestMessage): The request message to send.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Sequence[ResponseMessage | FailedRequestMessage]: The final response
|
|
115
|
+
messages, which can be either successful responses or failed requests.
|
|
116
|
+
"""
|
|
117
|
+
if message.total_attempts > 0:
|
|
118
|
+
raise RuntimeError(f"RequestMessage has already been attempted {message.total_attempts} times.")
|
|
119
|
+
pending_requests: deque[RequestMessage] = deque()
|
|
120
|
+
pending_requests.append(message)
|
|
121
|
+
final_responses: list[ResponseMessage | FailedRequestMessage] = []
|
|
122
|
+
|
|
123
|
+
while pending_requests:
|
|
124
|
+
current_request = pending_requests.popleft()
|
|
125
|
+
results = self.request(current_request)
|
|
126
|
+
|
|
127
|
+
for result in results:
|
|
128
|
+
if isinstance(result, RequestMessage):
|
|
129
|
+
pending_requests.append(result)
|
|
130
|
+
elif isinstance(result, ResponseMessage | FailedRequestMessage):
|
|
131
|
+
final_responses.append(result)
|
|
132
|
+
else:
|
|
133
|
+
raise TypeError(f"Unexpected result type: {type(result)}")
|
|
134
|
+
|
|
135
|
+
return final_responses
|
|
136
|
+
|
|
137
|
+
def _create_thread_safe_session(self) -> httpx.Client:
|
|
138
|
+
return httpx.Client(
|
|
139
|
+
limits=httpx.Limits(
|
|
140
|
+
max_connections=self._pool_maxsize,
|
|
141
|
+
max_keepalive_connections=self._pool_connections,
|
|
142
|
+
),
|
|
143
|
+
timeout=self.config.timeout,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _create_headers(self, api_version: str | None = None) -> MutableMapping[str, str]:
|
|
147
|
+
headers: MutableMapping[str, str] = {}
|
|
148
|
+
headers["User-Agent"] = f"httpx/{httpx.__version__} {get_user_agent()}"
|
|
149
|
+
auth_name, auth_value = self.config.credentials.authorization_header()
|
|
150
|
+
headers[auth_name] = auth_value
|
|
151
|
+
headers["content-type"] = "application/json"
|
|
152
|
+
headers["accept"] = "application/json"
|
|
153
|
+
headers["x-cdp-sdk"] = f"CogniteNeat:{get_current_neat_version()}"
|
|
154
|
+
headers["x-cdp-app"] = self.config.client_name
|
|
155
|
+
headers["cdf-version"] = api_version or self.config.api_subversion
|
|
156
|
+
if not global_config.disable_gzip:
|
|
157
|
+
headers["Content-Encoding"] = "gzip"
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _prepare_payload(item: BodyRequest) -> str | bytes:
|
|
162
|
+
"""
|
|
163
|
+
Prepare the payload for the HTTP request.
|
|
164
|
+
This method should be overridden in subclasses to customize the payload format.
|
|
165
|
+
"""
|
|
166
|
+
data: str | bytes
|
|
167
|
+
try:
|
|
168
|
+
data = _json.dumps(item.body(), allow_nan=False)
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
# A lot of work to give a more human friendly error message when nans and infs are present:
|
|
171
|
+
msg = "Out of range float values are not JSON compliant"
|
|
172
|
+
if msg in str(e): # exc. might e.g. contain an extra ": nan", depending on build (_json.make_encoder)
|
|
173
|
+
raise ValueError(f"{msg}. Make sure your data does not contain NaN(s) or +/- Inf!").with_traceback(
|
|
174
|
+
e.__traceback__
|
|
175
|
+
) from None
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
if not global_config.disable_gzip:
|
|
179
|
+
data = gzip.compress(data.encode())
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
def _make_request(self, item: RequestMessage) -> httpx.Response:
|
|
183
|
+
headers = self._create_headers(item.api_version)
|
|
184
|
+
params: dict[str, str] | None = None
|
|
185
|
+
if isinstance(item, ParamRequest):
|
|
186
|
+
params = item.parameters
|
|
187
|
+
data: str | bytes | None = None
|
|
188
|
+
if isinstance(item, BodyRequest):
|
|
189
|
+
data = self._prepare_payload(item)
|
|
190
|
+
return self.session.request(
|
|
191
|
+
method=item.method,
|
|
192
|
+
url=item.endpoint_url,
|
|
193
|
+
content=data,
|
|
194
|
+
headers=headers,
|
|
195
|
+
params=params,
|
|
196
|
+
timeout=self.config.timeout,
|
|
197
|
+
follow_redirects=False,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _handle_response(
|
|
201
|
+
self,
|
|
202
|
+
response: httpx.Response,
|
|
203
|
+
request: RequestMessage,
|
|
204
|
+
) -> Sequence[HTTPMessage]:
|
|
205
|
+
try:
|
|
206
|
+
body = response.json()
|
|
207
|
+
except ValueError as e:
|
|
208
|
+
return request.create_responses(response, error_message=f"Invalid JSON response: {e!s}")
|
|
209
|
+
|
|
210
|
+
error_obj = body.get("error", {})
|
|
211
|
+
is_auto_retryable = False
|
|
212
|
+
if isinstance(error_obj, dict):
|
|
213
|
+
is_auto_retryable = error_obj.get("isAutoRetryable", False)
|
|
214
|
+
|
|
215
|
+
if 200 <= response.status_code < 300:
|
|
216
|
+
return request.create_responses(response, body)
|
|
217
|
+
elif (
|
|
218
|
+
isinstance(request, ItemsRequest)
|
|
219
|
+
and len(request.items) > 1
|
|
220
|
+
and response.status_code in self._split_items_status_codes
|
|
221
|
+
):
|
|
222
|
+
# 4XX: Status there is at least one item that is invalid, split the batch to get all valid items processed
|
|
223
|
+
# 5xx: Server error, split to reduce the number of items in each request, and count as a status attempt
|
|
224
|
+
status_attempts = request.status_attempt
|
|
225
|
+
if 500 <= response.status_code < 600:
|
|
226
|
+
status_attempts += 1
|
|
227
|
+
splits = request.split(status_attempts=status_attempts)
|
|
228
|
+
if splits[0].tracker and splits[0].tracker.limit_reached():
|
|
229
|
+
return request.create_responses(response, body, self._get_error_message(body, response.text))
|
|
230
|
+
return splits
|
|
231
|
+
elif request.status_attempt < self._max_retries and (
|
|
232
|
+
response.status_code in self._retry_status_codes or is_auto_retryable
|
|
233
|
+
):
|
|
234
|
+
request.status_attempt += 1
|
|
235
|
+
time.sleep(self._backoff_time(request.total_attempts))
|
|
236
|
+
return [request]
|
|
237
|
+
else:
|
|
238
|
+
# Permanent failure
|
|
239
|
+
return request.create_responses(response, body, self._get_error_message(body, response.text))
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _get_error_message(body: JsonVal, default: str) -> str:
|
|
243
|
+
error = default
|
|
244
|
+
if not isinstance(body, dict):
|
|
245
|
+
return error
|
|
246
|
+
if "error" not in body:
|
|
247
|
+
return error
|
|
248
|
+
error_nested = body["error"]
|
|
249
|
+
if isinstance(error_nested, str):
|
|
250
|
+
return error_nested
|
|
251
|
+
if isinstance(error_nested, dict) and "message" in error_nested and isinstance(error_nested["message"], str):
|
|
252
|
+
return error_nested["message"]
|
|
253
|
+
return error
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _backoff_time(attempts: int) -> float:
|
|
257
|
+
backoff_time = 0.5 * (2**attempts)
|
|
258
|
+
return min(backoff_time, global_config.max_retry_backoff) * random.uniform(0, 1.0)
|
|
259
|
+
|
|
260
|
+
def _handle_error(
|
|
261
|
+
self,
|
|
262
|
+
e: Exception,
|
|
263
|
+
request: RequestMessage,
|
|
264
|
+
) -> Sequence[HTTPMessage]:
|
|
265
|
+
if isinstance(e, httpx.ReadTimeout | httpx.TimeoutException):
|
|
266
|
+
error_type = "read"
|
|
267
|
+
request.read_attempt += 1
|
|
268
|
+
attempts = request.read_attempt
|
|
269
|
+
elif isinstance(e, ConnectionError | httpx.ConnectError | httpx.ConnectTimeout):
|
|
270
|
+
error_type = "connect"
|
|
271
|
+
request.connect_attempt += 1
|
|
272
|
+
attempts = request.connect_attempt
|
|
273
|
+
else:
|
|
274
|
+
error_msg = f"Unexpected exception: {e!s}"
|
|
275
|
+
return request.create_failed_request(error_msg)
|
|
276
|
+
|
|
277
|
+
if attempts <= self._max_retries:
|
|
278
|
+
time.sleep(self._backoff_time(request.total_attempts))
|
|
279
|
+
return [request]
|
|
280
|
+
else:
|
|
281
|
+
# We have already incremented the attempt count, so we subtract 1 here
|
|
282
|
+
error_msg = f"RequestException after {request.total_attempts - 1} attempts ({error_type} error): {e!s}"
|
|
283
|
+
|
|
284
|
+
return request.create_failed_request(error_msg)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import platform
|
|
3
|
+
|
|
4
|
+
from cognite.neat._utils.auxiliary import get_current_neat_version
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@functools.lru_cache(maxsize=1)
|
|
8
|
+
def get_user_agent() -> str:
|
|
9
|
+
neat_version = f"CogniteNeat/{get_current_neat_version()}"
|
|
10
|
+
python_version = (
|
|
11
|
+
f"{platform.python_implementation()}/{platform.python_version()} "
|
|
12
|
+
f"({platform.python_build()};{platform.python_compiler()})"
|
|
13
|
+
)
|
|
14
|
+
os_version_info = [platform.release(), platform.machine(), platform.architecture()[0]]
|
|
15
|
+
os_version_info = [s for s in os_version_info if s] # Ignore empty strings
|
|
16
|
+
os_version_info_str = "-".join(os_version_info)
|
|
17
|
+
operating_system = f"{platform.system()}/{os_version_info_str}"
|
|
18
|
+
|
|
19
|
+
return f"{neat_version} {python_version} {operating_system}"
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Generic, Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from cognite.neat._utils.http_client._tracker import ItemsRequestTracker
|
|
9
|
+
from cognite.neat._utils.useful_types import T_ID, JsonVal
|
|
10
|
+
|
|
11
|
+
StatusCode: TypeAlias = int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HTTPMessage:
|
|
16
|
+
"""Base class for HTTP messages (requests and responses)"""
|
|
17
|
+
|
|
18
|
+
def dump(self) -> dict[str, JsonVal]:
|
|
19
|
+
"""Dumps the message to a JSON serializable dictionary.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
dict[str, JsonVal]: The message as a dictionary.
|
|
23
|
+
"""
|
|
24
|
+
# We avoid using the asdict function as we know we have a shallow structure,
|
|
25
|
+
# and this roughly ~10x faster.
|
|
26
|
+
output = self.__dict__.copy()
|
|
27
|
+
output["type"] = type(self).__name__
|
|
28
|
+
return output
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FailedRequestMessage(HTTPMessage):
|
|
33
|
+
error: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ResponseMessage(HTTPMessage):
|
|
38
|
+
status_code: StatusCode
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RequestMessage(HTTPMessage):
|
|
43
|
+
"""Base class for HTTP request messages"""
|
|
44
|
+
|
|
45
|
+
endpoint_url: str
|
|
46
|
+
method: Literal["GET", "POST", "PATCH", "DELETE"]
|
|
47
|
+
connect_attempt: int = 0
|
|
48
|
+
read_attempt: int = 0
|
|
49
|
+
status_attempt: int = 0
|
|
50
|
+
api_version: str | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_attempts(self) -> int:
|
|
54
|
+
return self.connect_attempt + self.read_attempt + self.status_attempt
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def create_responses(
|
|
58
|
+
self,
|
|
59
|
+
response: httpx.Response,
|
|
60
|
+
response_body: dict[str, JsonVal] | None = None,
|
|
61
|
+
error_message: str | None = None,
|
|
62
|
+
) -> Sequence[HTTPMessage]:
|
|
63
|
+
raise NotImplementedError()
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def create_failed_request(self, error_message: str) -> Sequence[HTTPMessage]:
|
|
67
|
+
raise NotImplementedError()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SuccessResponse(ResponseMessage):
|
|
72
|
+
body: dict[str, JsonVal] | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class FailedResponse(ResponseMessage):
|
|
77
|
+
error: str
|
|
78
|
+
body: dict[str, JsonVal] | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SimpleRequest(RequestMessage):
|
|
83
|
+
"""Base class for requests with a simple success/fail response structure"""
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def create_responses(
|
|
87
|
+
cls,
|
|
88
|
+
response: httpx.Response,
|
|
89
|
+
response_body: dict[str, JsonVal] | None = None,
|
|
90
|
+
error_message: str | None = None,
|
|
91
|
+
) -> Sequence[ResponseMessage]:
|
|
92
|
+
if 200 <= response.status_code < 300 and error_message is None:
|
|
93
|
+
return [SuccessResponse(status_code=response.status_code, body=response_body)]
|
|
94
|
+
if error_message is None:
|
|
95
|
+
error_message = f"Request failed with status code {response.status_code}"
|
|
96
|
+
return [FailedResponse(status_code=response.status_code, error=error_message, body=response_body)]
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def create_failed_request(cls, error_message: str) -> Sequence[HTTPMessage]:
|
|
100
|
+
return [FailedRequestMessage(error=error_message)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class BodyRequest(RequestMessage, ABC):
|
|
105
|
+
"""Base class for HTTP request messages with a body"""
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def body(self) -> dict[str, JsonVal]:
|
|
109
|
+
raise NotImplementedError()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ParamRequest(SimpleRequest):
|
|
114
|
+
"""Base class for HTTP request messages with query parameters"""
|
|
115
|
+
|
|
116
|
+
parameters: dict[str, str] | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class SimpleBodyRequest(SimpleRequest, BodyRequest):
|
|
121
|
+
body_content: dict[str, JsonVal] = field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
def body(self) -> dict[str, JsonVal]:
|
|
124
|
+
return self.body_content
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class ItemMessage:
|
|
129
|
+
"""Base class for message related to a specific item"""
|
|
130
|
+
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ItemIDMessage(Generic[T_ID], ItemMessage, ABC):
|
|
136
|
+
"""Base class for message related to a specific item identified by an ID"""
|
|
137
|
+
|
|
138
|
+
id: T_ID
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ItemResponse(ItemIDMessage, ResponseMessage, ABC): ...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class SuccessItem(ItemResponse):
|
|
147
|
+
item: JsonVal | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class FailedItem(ItemResponse):
|
|
152
|
+
error: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class MissingItem(ItemResponse): ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class UnexpectedItem(ItemResponse):
|
|
161
|
+
item: JsonVal | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class FailedRequestItem(ItemIDMessage, FailedRequestMessage): ...
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class UnknownRequestItem(ItemMessage, FailedRequestMessage):
|
|
170
|
+
item: JsonVal | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class UnknownResponseItem(ItemMessage, ResponseMessage):
|
|
175
|
+
error: str
|
|
176
|
+
item: JsonVal | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ItemsRequest(Generic[T_ID], BodyRequest):
|
|
181
|
+
"""Requests message for endpoints that accept multiple items in a single request.
|
|
182
|
+
|
|
183
|
+
This class provides functionality to split large requests into smaller ones, handle responses for each item,
|
|
184
|
+
and manage errors effectively.
|
|
185
|
+
|
|
186
|
+
Attributes:
|
|
187
|
+
items (list[JsonVal]): The list of items to be sent in the request body.
|
|
188
|
+
extra_body_fields (dict[str, JsonVal]): Additional fields to include in the request body
|
|
189
|
+
as_id (Callable[[JsonVal], T_ID] | None): A function to extract the ID from each item. If None,
|
|
190
|
+
IDs are not used.
|
|
191
|
+
max_failures_before_abort (int): The maximum number of failed split requests before aborting further splits.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
items: list[JsonVal] = field(default_factory=list)
|
|
196
|
+
extra_body_fields: dict[str, JsonVal] = field(default_factory=dict)
|
|
197
|
+
as_id: Callable[[JsonVal], T_ID] | None = None
|
|
198
|
+
max_failures_before_abort: int = 50
|
|
199
|
+
tracker: ItemsRequestTracker | None = field(default=None, init=False)
|
|
200
|
+
|
|
201
|
+
def dump(self) -> dict[str, JsonVal]:
|
|
202
|
+
"""Dumps the message to a JSON serializable dictionary.
|
|
203
|
+
|
|
204
|
+
This override removes the 'as_id' attribute as it is not serializable.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
dict[str, JsonVal]: The message as a dictionary.
|
|
208
|
+
"""
|
|
209
|
+
output = super().dump()
|
|
210
|
+
if self.as_id is not None:
|
|
211
|
+
# We cannot serialize functions
|
|
212
|
+
del output["as_id"]
|
|
213
|
+
if self.tracker is not None:
|
|
214
|
+
# We cannot serialize the tracker
|
|
215
|
+
del output["tracker"]
|
|
216
|
+
return output
|
|
217
|
+
|
|
218
|
+
def body(self) -> dict[str, JsonVal]:
|
|
219
|
+
if self.extra_body_fields:
|
|
220
|
+
return {"items": self.items, **self.extra_body_fields}
|
|
221
|
+
return {"items": self.items}
|
|
222
|
+
|
|
223
|
+
def split(self, status_attempts: int) -> "list[ItemsRequest]":
|
|
224
|
+
"""Splits the request into two smaller requests.
|
|
225
|
+
|
|
226
|
+
This is useful for retrying requests that fail due to size limits or timeouts.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
status_attempts: The number of status attempts to set for the new requests. This is used when the
|
|
230
|
+
request failed with a 5xx status code and we want to track the number of attempts. For 4xx errors,
|
|
231
|
+
there is at least one item causing the error, so we do not increment the status attempts, but
|
|
232
|
+
instead essentially do a binary search to find the problematic item(s).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
A list containing two new ItemsRequest instances, each with half of the original items.
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
mid = len(self.items) // 2
|
|
239
|
+
if mid == 0:
|
|
240
|
+
return [self]
|
|
241
|
+
tracker = self.tracker or ItemsRequestTracker(self.max_failures_before_abort)
|
|
242
|
+
tracker.register_failure()
|
|
243
|
+
first_half = ItemsRequest[T_ID](
|
|
244
|
+
endpoint_url=self.endpoint_url,
|
|
245
|
+
method=self.method,
|
|
246
|
+
items=self.items[:mid],
|
|
247
|
+
extra_body_fields=self.extra_body_fields,
|
|
248
|
+
as_id=self.as_id,
|
|
249
|
+
connect_attempt=self.connect_attempt,
|
|
250
|
+
read_attempt=self.read_attempt,
|
|
251
|
+
status_attempt=status_attempts,
|
|
252
|
+
)
|
|
253
|
+
first_half.tracker = tracker
|
|
254
|
+
second_half = ItemsRequest[T_ID](
|
|
255
|
+
endpoint_url=self.endpoint_url,
|
|
256
|
+
method=self.method,
|
|
257
|
+
items=self.items[mid:],
|
|
258
|
+
extra_body_fields=self.extra_body_fields,
|
|
259
|
+
as_id=self.as_id,
|
|
260
|
+
connect_attempt=self.connect_attempt,
|
|
261
|
+
read_attempt=self.read_attempt,
|
|
262
|
+
status_attempt=status_attempts,
|
|
263
|
+
)
|
|
264
|
+
second_half.tracker = tracker
|
|
265
|
+
return [first_half, second_half]
|
|
266
|
+
|
|
267
|
+
def create_responses(
|
|
268
|
+
self,
|
|
269
|
+
response: httpx.Response,
|
|
270
|
+
response_body: dict[str, JsonVal] | None = None,
|
|
271
|
+
error_message: str | None = None,
|
|
272
|
+
) -> Sequence[HTTPMessage]:
|
|
273
|
+
"""Creates response messages based on the HTTP response and the original request.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
response: The HTTP response received from the server.
|
|
277
|
+
response_body: The parsed JSON body of the response, if available.
|
|
278
|
+
error_message: An optional error message to use if the response indicates a failure.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
A sequence of HTTPMessage instances representing the outcome for each item in the request.
|
|
282
|
+
"""
|
|
283
|
+
if self.as_id is None:
|
|
284
|
+
return SimpleBodyRequest.create_responses(response, response_body, error_message)
|
|
285
|
+
request_items_by_id, errors = self._create_items_by_id()
|
|
286
|
+
responses: list[HTTPMessage] = list(errors)
|
|
287
|
+
error_message = error_message or "Unknown error"
|
|
288
|
+
|
|
289
|
+
if not self._is_items_response(response_body):
|
|
290
|
+
return self._handle_non_items_response(responses, response, error_message, request_items_by_id)
|
|
291
|
+
|
|
292
|
+
# Process items from response
|
|
293
|
+
if response_body is not None:
|
|
294
|
+
self._process_response_items(responses, response, response_body, error_message, request_items_by_id)
|
|
295
|
+
|
|
296
|
+
# Handle missing items
|
|
297
|
+
self._handle_missing_items(responses, response, request_items_by_id)
|
|
298
|
+
|
|
299
|
+
return responses
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _handle_non_items_response(
|
|
303
|
+
responses: list[HTTPMessage],
|
|
304
|
+
response: httpx.Response,
|
|
305
|
+
error_message: str,
|
|
306
|
+
request_items_by_id: dict[T_ID, JsonVal],
|
|
307
|
+
) -> list[HTTPMessage]:
|
|
308
|
+
"""Handles responses that do not contain an 'items' field in the body."""
|
|
309
|
+
if 200 <= response.status_code < 300:
|
|
310
|
+
responses.extend(
|
|
311
|
+
SuccessItem(status_code=response.status_code, id=id_) for id_ in request_items_by_id.keys()
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
responses.extend(
|
|
315
|
+
FailedItem(status_code=response.status_code, error=error_message, id=id_)
|
|
316
|
+
for id_ in request_items_by_id.keys()
|
|
317
|
+
)
|
|
318
|
+
return responses
|
|
319
|
+
|
|
320
|
+
def _process_response_items(
|
|
321
|
+
self,
|
|
322
|
+
responses: list[HTTPMessage],
|
|
323
|
+
response: httpx.Response,
|
|
324
|
+
response_body: dict[str, JsonVal],
|
|
325
|
+
error_message: str,
|
|
326
|
+
request_items_by_id: dict[T_ID, JsonVal],
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Processes each item in the response body and categorizes them based on their status."""
|
|
329
|
+
for response_item in response_body["items"]: # type: ignore[union-attr]
|
|
330
|
+
try:
|
|
331
|
+
item_id = self.as_id(response_item) # type: ignore[misc]
|
|
332
|
+
except Exception as e:
|
|
333
|
+
responses.append(
|
|
334
|
+
UnknownResponseItem(
|
|
335
|
+
status_code=response.status_code, item=response_item, error=f"Error extracting ID: {e!s}"
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
continue
|
|
339
|
+
request_item = request_items_by_id.pop(item_id, None)
|
|
340
|
+
if request_item is None:
|
|
341
|
+
responses.append(UnexpectedItem(status_code=response.status_code, id=item_id, item=response_item))
|
|
342
|
+
elif 200 <= response.status_code < 300:
|
|
343
|
+
responses.append(SuccessItem(status_code=response.status_code, id=item_id, item=response_item))
|
|
344
|
+
else:
|
|
345
|
+
responses.append(FailedItem(status_code=response.status_code, id=item_id, error=error_message))
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _handle_missing_items(
|
|
349
|
+
responses: list[HTTPMessage], response: httpx.Response, request_items_by_id: dict[T_ID, JsonVal]
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Handles items that were in the request but not present in the response."""
|
|
352
|
+
for item_id in request_items_by_id.keys():
|
|
353
|
+
responses.append(MissingItem(status_code=response.status_code, id=item_id))
|
|
354
|
+
|
|
355
|
+
def create_failed_request(self, error_message: str) -> Sequence[HTTPMessage]:
|
|
356
|
+
if self.as_id is None:
|
|
357
|
+
return SimpleBodyRequest.create_failed_request(error_message)
|
|
358
|
+
items_by_id, errors = self._create_items_by_id()
|
|
359
|
+
results: list[HTTPMessage] = []
|
|
360
|
+
results.extend(errors)
|
|
361
|
+
results.extend(FailedRequestItem(id=item_id, error=error_message) for item_id in items_by_id.keys())
|
|
362
|
+
return results
|
|
363
|
+
|
|
364
|
+
def _create_items_by_id(self) -> tuple[dict[T_ID, JsonVal], list[FailedRequestItem | UnknownRequestItem]]:
|
|
365
|
+
if self.as_id is None:
|
|
366
|
+
raise ValueError("as_id function must be provided to create items by ID")
|
|
367
|
+
items_by_id: dict[T_ID, JsonVal] = {}
|
|
368
|
+
errors: list[FailedRequestItem | UnknownRequestItem] = []
|
|
369
|
+
for item in self.items:
|
|
370
|
+
try:
|
|
371
|
+
item_id = self.as_id(item)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
errors.append(UnknownRequestItem(error=f"Error extracting ID: {e!s}", item=item))
|
|
374
|
+
continue
|
|
375
|
+
if item_id in items_by_id:
|
|
376
|
+
errors.append(FailedRequestItem(id=item_id, error=f"Duplicate item ID: {item_id!r}"))
|
|
377
|
+
else:
|
|
378
|
+
items_by_id[item_id] = item
|
|
379
|
+
return items_by_id, errors
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _is_items_response(body: dict[str, JsonVal] | None = None) -> bool:
|
|
383
|
+
if body is None:
|
|
384
|
+
return False
|
|
385
|
+
if "items" not in body:
|
|
386
|
+
return False
|
|
387
|
+
if not isinstance(body["items"], list):
|
|
388
|
+
return False
|
|
389
|
+
return True
|