helix.fhir.client.sdk 4.2.18__py3-none-any.whl → 4.2.19__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.
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import time
2
3
  from abc import ABC
3
4
  from collections.abc import AsyncGenerator
4
5
  from datetime import UTC, datetime
@@ -123,6 +124,15 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
123
124
  Yields:
124
125
  FhirGetResponse objects representing retrieved resources
125
126
  """
127
+
128
+ profiling: dict[str, Any] = {
129
+ "function": "process_simulate_graph_async",
130
+ "start_time": time.perf_counter(),
131
+ "steps": {},
132
+ "extend_calls": [],
133
+ "append_calls": [],
134
+ }
135
+
126
136
  # Validate graph definition input
127
137
  assert graph_json, "Graph JSON must be provided"
128
138
  graph_definition: GraphDefinition = GraphDefinition.from_dict(graph_json)
@@ -158,6 +168,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
158
168
  cache: RequestCache = input_cache if input_cache is not None else RequestCache()
159
169
  async with cache:
160
170
  # Retrieve start resources based on graph definition
171
+ step_start = time.perf_counter()
161
172
  start: str = graph_definition.start
162
173
  parent_response: FhirGetResponse
163
174
  cache_hits: int
@@ -171,10 +182,18 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
171
182
  add_cached_bundles_to_result=add_cached_bundles_to_result,
172
183
  compare_hash=compare_hash,
173
184
  )
185
+ profiling["steps"]["get_start_resources"] = time.perf_counter() - step_start
174
186
 
175
187
  # If no parent resources found, yield empty response and exit
176
188
  parent_response_resource_count = parent_response.get_resource_count()
177
189
  if parent_response_resource_count == 0:
190
+ profiling["total_time"] = time.perf_counter() - profiling["start_time"]
191
+ if logger:
192
+ logger.info(
193
+ f"[PROFILING] process_simulate_graph_async: total={profiling['total_time']:.3f}s, "
194
+ f"get_start_resources={profiling['steps'].get('get_start_resources', 0):.3f}s, "
195
+ f"no parent resources found"
196
+ )
178
197
  yield parent_response
179
198
  return # no resources to process
180
199
 
@@ -196,6 +215,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
196
215
  )
197
216
 
198
217
  # now process the graph links
218
+ step_start = time.perf_counter()
199
219
  child_responses: list[FhirGetResponse] = []
200
220
  parent_link_map: list[tuple[list[GraphDefinitionLink], FhirBundleEntryList]] = []
201
221
 
@@ -204,6 +224,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
204
224
  parent_link_map.append((graph_definition.link, parent_bundle_entries))
205
225
 
206
226
  # Process graph links in parallel
227
+ link_processing_count = 0
207
228
  while len(parent_link_map):
208
229
  new_parent_link_map: list[tuple[list[GraphDefinitionLink], FhirBundleEntryList]] = []
209
230
 
@@ -230,19 +251,38 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
230
251
  add_cached_bundles_to_result=add_cached_bundles_to_result,
231
252
  ifModifiedSince=ifModifiedSince,
232
253
  ):
254
+ # Track extend operation
255
+ extend_start = time.perf_counter()
233
256
  child_responses.extend(link_responses)
257
+ extend_time = time.perf_counter() - extend_start
258
+ profiling["extend_calls"].append(
259
+ {"location": "child_responses.extend", "count": len(link_responses), "time": extend_time}
260
+ )
261
+ link_processing_count += 1
234
262
 
235
263
  # Update parent link map for next iteration
236
264
  parent_link_map = new_parent_link_map
237
265
 
266
+ profiling["steps"]["process_graph_links"] = time.perf_counter() - step_start
267
+ profiling["steps"]["link_processing_iterations"] = link_processing_count
268
+
238
269
  # Combine and process responses
270
+ step_start = time.perf_counter()
239
271
  parent_response = cast(FhirGetBundleResponse, parent_response.extend(child_responses))
272
+ extend_time = time.perf_counter() - step_start
273
+ profiling["steps"]["parent_response.extend"] = extend_time
274
+ profiling["extend_calls"].append(
275
+ {"location": "parent_response.extend", "count": len(child_responses), "time": extend_time}
276
+ )
240
277
 
241
278
  # Optional resource sorting
242
279
  if sort_resources:
280
+ step_start = time.perf_counter()
243
281
  parent_response = parent_response.sort_resources()
282
+ profiling["steps"]["sort_resources"] = time.perf_counter() - step_start
244
283
 
245
284
  # Prepare final response based on bundling preferences
285
+ step_start = time.perf_counter()
246
286
  full_response: FhirGetResponse
247
287
  if separate_bundle_resources:
248
288
  full_response = FhirGetListByResourceTypeResponse.from_response(other_response=parent_response)
@@ -250,10 +290,38 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
250
290
  full_response = FhirGetListResponse.from_response(other_response=parent_response)
251
291
  else:
252
292
  full_response = parent_response
293
+ profiling["steps"]["prepare_final_response"] = time.perf_counter() - step_start
253
294
 
254
295
  # Set response URL
255
296
  full_response.url = url or parent_response.url
256
297
 
298
+ # Calculate profiling summary
299
+ profiling["total_time"] = time.perf_counter() - profiling["start_time"]
300
+ total_extend_time = sum(call["time"] for call in profiling["extend_calls"])
301
+ total_extend_count = sum(call["count"] for call in profiling["extend_calls"])
302
+
303
+ # Log profiling information
304
+ if logger:
305
+ logger.info(
306
+ f"[PROFILING] process_simulate_graph_async for id={id_}: "
307
+ f"total={profiling['total_time']:.3f}s, "
308
+ f"get_start_resources={profiling['steps'].get('get_start_resources', 0):.3f}s, "
309
+ f"process_graph_links={profiling['steps'].get('process_graph_links', 0):.3f}s, "
310
+ f"parent_response.extend={profiling['steps'].get('parent_response.extend', 0):.3f}s, "
311
+ f"sort_resources={profiling['steps'].get('sort_resources', 0):.3f}s, "
312
+ f"prepare_final_response={profiling['steps'].get('prepare_final_response', 0):.3f}s"
313
+ )
314
+ logger.info(
315
+ f"[PROFILING] process_simulate_graph_async extend operations: "
316
+ f"total_calls={len(profiling['extend_calls'])}, "
317
+ f"total_items={total_extend_count}, "
318
+ f"total_time={total_extend_time:.3f}s"
319
+ )
320
+ for call in profiling["extend_calls"]:
321
+ logger.info(
322
+ f"[PROFILING] extend at {call['location']}: items={call['count']}, time={call['time']:.3f}s"
323
+ )
324
+
257
325
  # Log cache performance
258
326
  if logger:
259
327
  logger.info(
@@ -276,26 +344,9 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
276
344
  ) -> list[FhirGetResponse]:
277
345
  """
278
346
  Parallel processing function for graph definition links.
279
-
280
- This method is designed to be used with AsyncParallelProcessor to process
281
- graph links concurrently, improving performance for complex FHIR resource
282
- graph traversals.
283
-
284
- Key Responsibilities:
285
- - Process individual graph links in parallel
286
- - Track and log processing details
287
- - Handle resource retrieval for each link
288
- - Manage parallel processing context
289
-
290
- Args:
291
- context: Parallel processing context information
292
- row: Current GraphDefinitionLink being processed
293
- parameters: Parameters for link processing
294
- additional_parameters: Extra parameters for extended processing
295
-
296
- Returns:
297
- List of FhirGetResponse objects retrieved during link processing
298
347
  """
348
+ profiling_start = time.perf_counter()
349
+
299
350
  # Record the start time for performance tracking
300
351
  start_time: datetime = datetime.now()
301
352
 
@@ -326,11 +377,8 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
326
377
  logger=parameters.logger,
327
378
  cache=parameters.cache,
328
379
  scope_parser=parameters.scope_parser,
329
- # Handle parent link map from additional parameters
330
380
  parent_link_map=(additional_parameters["parent_link_map"] if additional_parameters else []),
331
- # Determine request size, default to 1 if not specified
332
381
  request_size=(additional_parameters["request_size"] if additional_parameters else 1),
333
- # Track unsupported resources for ID-based search
334
382
  id_search_unsupported_resources=(
335
383
  additional_parameters["id_search_unsupported_resources"] if additional_parameters else []
336
384
  ),
@@ -346,6 +394,8 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
346
394
  # Record end time for performance tracking
347
395
  end_time: datetime = datetime.now()
348
396
 
397
+ total_time = time.perf_counter() - profiling_start
398
+
349
399
  # Log detailed processing information
350
400
  if parameters.logger:
351
401
  parameters.logger.debug(
@@ -357,6 +407,11 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
357
407
  + f" | duration: {end_time - start_time}"
358
408
  + f" | resource_count: {len(result)}"
359
409
  )
410
+ parameters.logger.info(
411
+ f"[PROFILING] process_link_async_parallel_function for path={row.path}: "
412
+ f"total={total_time:.3f}s, "
413
+ f"results={len(result)}"
414
+ )
360
415
 
361
416
  # Return the list of retrieved responses
362
417
  return result
@@ -842,9 +897,18 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
842
897
  logger: Logger | None,
843
898
  compare_hash: bool = True,
844
899
  ) -> FhirGetResponse | None:
900
+ profiling_start = time.perf_counter()
901
+ http_request_time = 0.0
902
+ http_request_count = 0
903
+ cache_check_time = 0.0
904
+ cache_update_time = 0.0
905
+ append_time = 0.0
906
+
845
907
  result: FhirGetResponse | None = None
846
908
  non_cached_id_list: list[str] = []
909
+
847
910
  # first check to see if we can find these in the cache
911
+ cache_check_start = time.perf_counter()
848
912
  if ids:
849
913
  for resource_id in ids:
850
914
  cache_entry: RequestCacheEntry | None = await cache.get_async(
@@ -857,9 +921,12 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
857
921
  if logger:
858
922
  logger.info(f"Cache entry not found for {resource_type}/{resource_id} (1by1)")
859
923
  non_cached_id_list.append(resource_id)
924
+ cache_check_time = time.perf_counter() - cache_check_start
860
925
 
926
+ cache_update_start = time.perf_counter()
861
927
  for single_id in non_cached_id_list:
862
928
  result2: FhirGetResponse
929
+ http_start = time.perf_counter()
863
930
  async for result2 in self._get_with_session_async(
864
931
  page_number=None,
865
932
  ids=[single_id],
@@ -868,10 +935,15 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
868
935
  fn_handle_streaming_chunk=None,
869
936
  resource_type=resource_type,
870
937
  ):
938
+ http_request_time += time.perf_counter() - http_start
939
+ http_request_count += 1
940
+
871
941
  if result2.resource_type == "OperationOutcome":
872
942
  result2 = FhirGetErrorResponse.from_response(other_response=result2)
873
943
  if result:
944
+ append_start = time.perf_counter()
874
945
  result = result.append(result2)
946
+ append_time += time.perf_counter() - append_start
875
947
  else:
876
948
  result = result2
877
949
  if result2.successful:
@@ -905,6 +977,21 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
905
977
  )
906
978
  if cache_updated and logger:
907
979
  logger.info(f"Inserted {result2.status} for {resource_type}/{single_id} into cache (1by1)")
980
+ cache_update_time = time.perf_counter() - cache_update_start - http_request_time - append_time
981
+
982
+ total_time = time.perf_counter() - profiling_start
983
+ processing_time = total_time - http_request_time - cache_check_time - cache_update_time - append_time
984
+
985
+ if logger and http_request_count > 0:
986
+ logger.info(
987
+ f"[PROFILING] _get_resources_by_id_one_by_one_async for {resource_type}: "
988
+ f"total={total_time:.3f}s, "
989
+ f"http_requests={http_request_time:.3f}s ({http_request_count} calls), "
990
+ f"cache_check={cache_check_time:.3f}s, "
991
+ f"cache_update={cache_update_time:.3f}s, "
992
+ f"append={append_time:.3f}s, "
993
+ f"processing={processing_time:.3f}s"
994
+ )
908
995
 
909
996
  return result
910
997
 
@@ -921,6 +1008,13 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
921
1008
  add_cached_bundles_to_result: bool = True,
922
1009
  compare_hash: bool = True,
923
1010
  ) -> tuple[FhirGetResponse, int]:
1011
+ profiling_start = time.perf_counter()
1012
+ http_request_time = 0.0
1013
+ http_request_count = 0
1014
+ cache_check_time = 0.0
1015
+ cache_update_time = 0.0
1016
+ append_time = 0.0
1017
+
924
1018
  assert resource_type
925
1019
  if not scope_parser.scope_allows(resource_type=resource_type):
926
1020
  if logger:
@@ -954,14 +1048,13 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
954
1048
 
955
1049
  non_cached_id_list: list[str] = []
956
1050
  # get any cached resources
1051
+ cache_check_start = time.perf_counter()
957
1052
  if id_list:
958
1053
  for resource_id in id_list:
959
1054
  cache_entry: RequestCacheEntry | None = await cache.get_async(
960
1055
  resource_type=resource_type, resource_id=resource_id
961
1056
  )
962
1057
  if cache_entry:
963
- # if there is an entry then it means we tried to get it in the past
964
- # so don't get it again whether we were successful or not
965
1058
  if logger:
966
1059
  logger.info(
967
1060
  f"{cache_entry.status} Returning {resource_type}/{resource_id} from cache (ByParam)"
@@ -970,6 +1063,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
970
1063
  if logger:
971
1064
  logger.info(f"Cache entry not found for {resource_type}/{resource_id} (ByParam)")
972
1065
  non_cached_id_list.append(resource_id)
1066
+ cache_check_time = time.perf_counter() - cache_check_start
973
1067
 
974
1068
  all_result: FhirGetResponse | None = None
975
1069
  # either we have non-cached ids or this is a query without id but has other parameters
@@ -981,6 +1075,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
981
1075
  # call the server to get the resources
982
1076
  result1: FhirGetResponse
983
1077
  result: FhirGetResponse | None
1078
+ http_start = time.perf_counter()
984
1079
  async for result1 in self._get_with_session_async(
985
1080
  page_number=None,
986
1081
  ids=non_cached_id_list,
@@ -989,6 +1084,8 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
989
1084
  fn_handle_streaming_chunk=None,
990
1085
  resource_type=resource_type,
991
1086
  ):
1087
+ http_request_time += time.perf_counter() - http_start
1088
+ http_request_count += 1
992
1089
  result = result1
993
1090
  # if we got a failure then check if we can get it one by one
994
1091
  if (not result or result.status != 200) and len(non_cached_id_list) > 1:
@@ -1001,6 +1098,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1001
1098
  f" Fetching one by one ids: {non_cached_id_list}"
1002
1099
  )
1003
1100
  # For some resources if search by _id doesn't work then fetch one by one.
1101
+ one_by_one_start = time.perf_counter()
1004
1102
  result = await self._get_resources_by_id_one_by_one_async(
1005
1103
  resource_type=resource_type,
1006
1104
  ids=non_cached_id_list,
@@ -1009,6 +1107,9 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1009
1107
  logger=logger,
1010
1108
  compare_hash=compare_hash,
1011
1109
  )
1110
+ one_by_one_time = time.perf_counter() - one_by_one_start
1111
+ http_request_time += one_by_one_time
1112
+ http_request_count += len(non_cached_id_list)
1012
1113
  else:
1013
1114
  if logger:
1014
1115
  logger.info(f"Fetched {resource_type} resources using _id for url {self._url}")
@@ -1024,11 +1125,14 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1024
1125
 
1025
1126
  # append to the response
1026
1127
  if all_result:
1128
+ append_start = time.perf_counter()
1027
1129
  all_result = all_result.append(result)
1130
+ append_time += time.perf_counter() - append_start
1028
1131
  else:
1029
1132
  all_result = result
1030
1133
  # If non_cached_id_list is not empty and resource_type does not support ?_id search then fetch it one by one
1031
1134
  elif len(non_cached_id_list):
1135
+ one_by_one_start = time.perf_counter()
1032
1136
  all_result = await self._get_resources_by_id_one_by_one_async(
1033
1137
  resource_type=resource_type,
1034
1138
  ids=non_cached_id_list,
@@ -1037,10 +1141,13 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1037
1141
  logger=logger,
1038
1142
  compare_hash=compare_hash,
1039
1143
  )
1144
+ http_request_time += time.perf_counter() - one_by_one_start
1145
+ http_request_count += len(non_cached_id_list)
1040
1146
 
1041
1147
  # This list tracks the non-cached ids that were found
1042
1148
  found_non_cached_id_list: list[str] = []
1043
1149
  # Cache the fetched entries
1150
+ cache_update_start = time.perf_counter()
1044
1151
  if all_result:
1045
1152
  non_cached_bundle_entry: FhirBundleEntry
1046
1153
  for non_cached_bundle_entry in all_result.get_bundle_entries():
@@ -1074,7 +1181,6 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1074
1181
  logger.debug(f"Inserted {resource_type}/{non_cached_resource_id} into cache (ByParam)")
1075
1182
  found_non_cached_id_list.append(non_cached_resource_id)
1076
1183
 
1077
- # now add all the non-cached ids that were NOT found to the cache too so we don't look for them again
1078
1184
  for non_cached_id in non_cached_id_list:
1079
1185
  if non_cached_id not in found_non_cached_id_list:
1080
1186
  cache_updated = await cache.add_async(
@@ -1089,6 +1195,7 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1089
1195
  )
1090
1196
  if cache_updated and logger:
1091
1197
  logger.info(f"Inserted 404 for {resource_type}/{non_cached_id} into cache (ByParam)")
1198
+ cache_update_time = time.perf_counter() - cache_update_start
1092
1199
 
1093
1200
  bundle_response: FhirGetBundleResponse = (
1094
1201
  FhirGetBundleResponse.from_response(other_response=all_result)
@@ -1130,6 +1237,21 @@ class SimulatedGraphProcessorMixin(ABC, FhirClientProtocol):
1130
1237
  storage_mode=self._storage_mode,
1131
1238
  )
1132
1239
  )
1240
+
1241
+ total_time = time.perf_counter() - profiling_start
1242
+ processing_time = total_time - http_request_time - cache_check_time - cache_update_time - append_time
1243
+
1244
+ if logger and http_request_count > 0:
1245
+ logger.info(
1246
+ f"[PROFILING] _get_resources_by_parameters_async for {resource_type}: "
1247
+ f"total={total_time:.3f}s, "
1248
+ f"http_requests={http_request_time:.3f}s ({http_request_count} calls), "
1249
+ f"cache_check={cache_check_time:.3f}s, "
1250
+ f"cache_update={cache_update_time:.3f}s, "
1251
+ f"append={append_time:.3f}s, "
1252
+ f"processing={processing_time:.3f}s"
1253
+ )
1254
+
1133
1255
  return bundle_response, cache.cache_hits
1134
1256
 
1135
1257
  # noinspection PyPep8Naming
@@ -170,9 +170,8 @@ class FhirGetResponse:
170
170
  """
171
171
  result: FhirGetResponse = self._extend(others=others)
172
172
 
173
- latest_chunk_number: list[int] = sorted([o.chunk_number for o in others if o.chunk_number], reverse=True)
174
- if len(latest_chunk_number) > 0:
175
- result.chunk_number = latest_chunk_number[0]
173
+ if others and others[-1].chunk_number:
174
+ result.chunk_number = others[-1].chunk_number
176
175
  return result
177
176
 
178
177
  @abstractmethod
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  from collections.abc import AsyncGenerator, Generator
3
4
  from datetime import datetime
4
5
  from logging import Logger
@@ -311,36 +312,56 @@ class FhirGetBundleResponse(FhirGetResponse):
311
312
  ) -> "FhirGetBundleResponse":
312
313
  """
313
314
  Removes the entries in the cache from the bundle
314
-
315
- :param request_cache: RequestCache object to remove the entries from
316
- :param compare_hash: if True, compare the hash of the resource with the hash in the cache
317
- :return: self
318
315
  """
319
- # remove all entries in the cache from the bundle
316
+ # Build a lookup of cache entries by (resource_type, id)
317
+ cache_map: dict[tuple[str, str], str | None] = {}
320
318
  async for cached_entry in request_cache.get_entries_async():
321
- if cached_entry.from_input_cache:
322
- for entry in self._bundle_entries:
323
- if (
324
- entry.resource
325
- and entry.resource.id is not None # only remove if resource has an id
326
- and entry.resource.id == cached_entry.id_
327
- and entry.resource.resource_type == cached_entry.resource_type
328
- and (
329
- not compare_hash
330
- or (
331
- ResourceHash().hash_value(json.dumps(json.loads(entry.resource.json()), sort_keys=True))
332
- == cached_entry.raw_hash
333
- )
334
- )
335
- ):
336
- if logger:
337
- logger.debug(
338
- f"Removing entry from bundle with id {entry.resource.id} and resource "
339
- f"type {entry.resource.resource_type}"
340
- )
341
- self._bundle_entries.remove(entry)
342
- break
319
+ if cached_entry.from_input_cache and cached_entry.resource_type and cached_entry.id_:
320
+ cache_map[(cached_entry.resource_type, cached_entry.id_)] = (
321
+ cached_entry.raw_hash if compare_hash else None
322
+ )
323
+
324
+ if not cache_map:
325
+ return self
326
+
327
+ resource_hash = ResourceHash() if compare_hash else None
328
+ removed_entries: list[FhirBundleEntry] = []
329
+
330
+ def should_remove(entry: FhirBundleEntry) -> bool:
331
+ resource = entry.resource
332
+ if not resource or resource.id is None or resource.resource_type is None:
333
+ return False
334
+ key = (resource.resource_type, resource.id)
335
+ if key not in cache_map:
336
+ return False
337
+ if not compare_hash:
338
+ return True
339
+ # Compare normalized JSON hash
340
+ if resource_hash is None:
341
+ return False
342
+ try:
343
+ entry_hash = resource_hash.hash_value(json.dumps(json.loads(resource.json()), sort_keys=True))
344
+ return entry_hash == cache_map[key]
345
+ except Exception:
346
+ return False
347
+
348
+ # One pass filter; rebuild list to avoid many deque.remove calls
349
+ kept: list[FhirBundleEntry] = []
350
+ for entry in self._bundle_entries:
351
+ if should_remove(entry):
352
+ removed_entries.append(entry)
353
+ else:
354
+ kept.append(entry)
355
+
356
+ if logger and removed_entries and logger.isEnabledFor(logging.DEBUG):
357
+ for entry in removed_entries:
358
+ if entry.resource:
359
+ logger.debug(
360
+ f"Removing entry from bundle with id {entry.resource.id} and resource "
361
+ f"type {entry.resource.resource_type}"
362
+ )
343
363
 
364
+ self._bundle_entries = FhirBundleEntryList(kept)
344
365
  return self
345
366
 
346
367
  @classmethod
@@ -1,5 +1,4 @@
1
1
  import dataclasses
2
- from copy import deepcopy
3
2
  from typing import Any, cast
4
3
 
5
4
 
@@ -27,46 +26,42 @@ class ResourceSeparator:
27
26
  extra_context_to_return: dict[str, Any] | None,
28
27
  ) -> ResourceSeparatorResult:
29
28
  """
30
- Given a list of resources, return a list of resources with the contained resources separated out.
31
-
32
- :param resources: The resources list.
33
- :param access_token: The access token.
34
- :param url: The URL.
35
- :param extra_context_to_return: The extra context to return.
36
-
37
- :return: None
29
+ Separate contained resources without copying or mutating input resources.
38
30
  """
39
31
  resources_dicts: list[dict[str, str | None | list[dict[str, Any]]]] = []
40
- resource_count: int = 0
41
- resource: dict[str, Any]
42
- for resource in resources:
43
- # make a copy so we are not changing the original resource
44
- cloned_resource: dict[str, Any] = deepcopy(resource)
45
- # This dict will hold the separated resources where the key is resourceType
46
- # have to split these here otherwise when Spark loads them
47
- # it can't handle that items in the entry array can have different schemas
48
- resources_dict: dict[str, str | None | list[dict[str, Any]]] = {}
49
- # add the parent resource to the resources_dict
50
- resource_type = str(cloned_resource["resourceType"]).lower()
51
- if resource_type not in resources_dict:
52
- resources_dict[resource_type] = []
53
- if isinstance(resources_dict[resource_type], list):
54
- cast(list[dict[str, Any]], resources_dict[resource_type]).append(cloned_resource)
55
- resource_count += 1
56
- # now see if this resource has a contained array and if so, add those to the resources_dict
57
- if "contained" in cloned_resource:
58
- contained_resources = cloned_resource.pop("contained")
59
- for contained_resource in contained_resources:
60
- resource_type = str(contained_resource["resourceType"]).lower()
61
- if resource_type not in resources_dict:
62
- resources_dict[resource_type] = []
63
- if isinstance(resources_dict[resource_type], list):
64
- cast(list[dict[str, Any]], resources_dict[resource_type]).append(contained_resource)
65
- resource_count += 1
66
- resources_dict["token"] = access_token
67
- resources_dict["url"] = url
32
+ total_resource_count: int = 0
33
+
34
+ for parent_resource in resources:
35
+ resource_type_value = parent_resource.get("resourceType")
36
+ if not resource_type_value:
37
+ continue
38
+
39
+ resource_type_key = str(resource_type_value).lower()
40
+ resource_map: dict[str, str | None | list[dict[str, Any]]] = {}
41
+
42
+ # Add parent resource
43
+ parent_list = cast(list[dict[str, Any]], resource_map.setdefault(resource_type_key, []))
44
+ parent_list.append(parent_resource)
45
+ total_resource_count += 1
46
+
47
+ # Add contained resources (if present) without mutating parent
48
+ contained_list = parent_resource.get("contained")
49
+ if isinstance(contained_list, list) and contained_list:
50
+ total_resource_count += len(contained_list)
51
+ for contained_resource in contained_list:
52
+ contained_type_value = contained_resource.get("resourceType")
53
+ if not contained_type_value:
54
+ continue
55
+ contained_type_key = str(contained_type_value).lower()
56
+ contained_list_out = cast(list[dict[str, Any]], resource_map.setdefault(contained_type_key, []))
57
+ contained_list_out.append(contained_resource)
58
+
59
+ # Context
60
+ resource_map["token"] = access_token
61
+ resource_map["url"] = url
68
62
  if extra_context_to_return:
69
- resources_dict.update(extra_context_to_return)
70
- resources_dicts.append(resources_dict)
63
+ resource_map.update(extra_context_to_return)
64
+
65
+ resources_dicts.append(resource_map)
71
66
 
72
- return ResourceSeparatorResult(resources_dicts=resources_dicts, total_count=resource_count)
67
+ return ResourceSeparatorResult(resources_dicts=resources_dicts, total_count=total_resource_count)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: helix.fhir.client.sdk
3
- Version: 4.2.18
3
+ Version: 4.2.19
4
4
  Summary: helix.fhir.client.sdk
5
5
  Home-page: https://github.com/icanbwell/helix.fhir.client.sdk
6
6
  Author: Imran Qureshi
@@ -32,7 +32,7 @@ helix_fhir_client_sdk/graph/fhir_graph_mixin.py,sha256=z0j9FmO2bOnmzgQmczfkWC70u
32
32
  helix_fhir_client_sdk/graph/graph_definition.py,sha256=FTa1GLjJ6oooAhNw7SPk-Y8duB-5WtJtnwADao-afaI,3878
33
33
  helix_fhir_client_sdk/graph/graph_link_parameters.py,sha256=3rknHL6SBgpT2A1fr-AikEFrR_9nIJUotZ82XFzROLo,599
34
34
  helix_fhir_client_sdk/graph/graph_target_parameters.py,sha256=fdYQpPZxDnyWyevuwDwxeTXOJoE2PgS5QhPaXpwtFcU,705
35
- helix_fhir_client_sdk/graph/simulated_graph_processor_mixin.py,sha256=h8K4peHcoY_4_Ln4Vz54Ls87W7bXegm4SZV2s2L4cCM,60302
35
+ helix_fhir_client_sdk/graph/simulated_graph_processor_mixin.py,sha256=CNXlqjkdrebypCY4JHmz1XbGT3kpcSpich9ourE76H0,66430
36
36
  helix_fhir_client_sdk/graph/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
37
  helix_fhir_client_sdk/graph/test/test_graph_mixin.py,sha256=LNd4LVjryVLgzWeTXMDpsbdauXl7u3LMfj9irnNfb_k,5469
38
38
  helix_fhir_client_sdk/graph/test/test_simulate_graph_processor_mixin.py,sha256=EQDfhqJfUrP6SptXRP7ayEN7g5cZQMA00ccXzeXiSXM,46312
@@ -46,15 +46,15 @@ helix_fhir_client_sdk/responses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm
46
46
  helix_fhir_client_sdk/responses/bundle_expander.py,sha256=ilR5eMgciSgzsdQvKB6bHtn9jtpvn3uS-EBz-hrahzo,1065
47
47
  helix_fhir_client_sdk/responses/fhir_client_protocol.py,sha256=IWM7LNQ1ZbgaySLGqpCwWAKyArT5HgBdNhkmSEivNMo,7100
48
48
  helix_fhir_client_sdk/responses/fhir_delete_response.py,sha256=0K11vyfZ0LtL-G61NHzDqHrZgEHjMVZr00VWpWcktZA,2941
49
- helix_fhir_client_sdk/responses/fhir_get_response.py,sha256=3PXvFoMZ7ix2ZzucIjY-49RL3foLR07dD57BdulyMGI,17657
49
+ helix_fhir_client_sdk/responses/fhir_get_response.py,sha256=v0ndKFb6o8PNqYYxGlLETzQnxGmwK3YympH4gotXpY4,17550
50
50
  helix_fhir_client_sdk/responses/fhir_merge_response.py,sha256=uvUjEGJgMDlAkcc5_LjgAsTFZqREQMlV79sbxUZwtwE,2862
51
51
  helix_fhir_client_sdk/responses/fhir_response_processor.py,sha256=fOSvqWjVI1BA6aDxxu9aqEvcKxtnFRmU2hMrgtmUCUM,34056
52
52
  helix_fhir_client_sdk/responses/fhir_update_response.py,sha256=_6zZz85KQP69WFxejlX8BBWAKWtzsMGSJjR_zqhl_m4,2727
53
53
  helix_fhir_client_sdk/responses/get_result.py,sha256=hkbZeu9h-01ZZckAuckn6UDR9GXGgRAIiKEN6ELRj80,1252
54
54
  helix_fhir_client_sdk/responses/paging_result.py,sha256=tpmfdgrtaAmmViVxlw-EBHoe0PVjSQW9zicwRmhUVpI,1360
55
- helix_fhir_client_sdk/responses/resource_separator.py,sha256=jugaEkJYunx8VGVFCLwWNSjrBlI8DDm61LzSx9oR8iE,3230
55
+ helix_fhir_client_sdk/responses/resource_separator.py,sha256=7Ic0_SPNKCBAk6l07Ke2-AoObkBMnKdmtbbtBBCtVjE,2606
56
56
  helix_fhir_client_sdk/responses/get/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- helix_fhir_client_sdk/responses/get/fhir_get_bundle_response.py,sha256=H-e-Rp_cwU508uJqQfDDCoJR5vN9-5DYsaW7K0yTsxM,18183
57
+ helix_fhir_client_sdk/responses/get/fhir_get_bundle_response.py,sha256=8xqTTfD4qwzMXeuDOc0tlxeOs0_OwobTeGLu2sthL4w,18818
58
58
  helix_fhir_client_sdk/responses/get/fhir_get_error_response.py,sha256=oNdKs_r7K4qRHD7fyeDhZz3v0wGTT2esAPPtDhxOyeI,12325
59
59
  helix_fhir_client_sdk/responses/get/fhir_get_list_by_resource_type_response.py,sha256=ssfb1IB2QTvqwWRzFs_VqPKH6mwcnWNo3iVh82M-Jso,13775
60
60
  helix_fhir_client_sdk/responses/get/fhir_get_list_response.py,sha256=KT5g6MjB9yWWUaSZpx1jK9Tm2yVmcFyZMHBBnDDAPtU,11858
@@ -130,7 +130,7 @@ helix_fhir_client_sdk/validators/async_fhir_validator.py,sha256=i1BC98hZF6JhMQQz
130
130
  helix_fhir_client_sdk/validators/fhir_validator.py,sha256=HWBldSEB9yeKIcnLcV8R-LoTzwT_OMu8SchtUUBKzys,2331
131
131
  helix_fhir_client_sdk/validators/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
132
132
  helix_fhir_client_sdk/validators/test/test_async_fhir_validator.py,sha256=RmSowjPUdZee5nYuYujghxWyqJ20cu7U0lJFtFT-ZBs,3285
133
- helix_fhir_client_sdk-4.2.18.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
133
+ helix_fhir_client_sdk-4.2.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
134
134
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
135
135
  tests/logger_for_test.py,sha256=UC-7F6w6fDsUIYf37aRnvUdiUUVk8qkJEUSuO17NQnI,1525
136
136
  tests/test_fhir_client_clone.py,sha256=c5y1rWJ32nBSUnK1FfyymY005dNowd4Nf1xrbuQolNk,5368
@@ -213,7 +213,7 @@ tests_integration/test_emr_server_auth.py,sha256=2I4QUAspQN89uGf6JB2aVuYaBeDnRJz
213
213
  tests_integration/test_firely_fhir.py,sha256=ll6-plwQrKfdrEyfbw0wLTC1jB-Qei1Mj-81tYTl5eQ,697
214
214
  tests_integration/test_merge_vs_smart_merge_behavior.py,sha256=LrIuyxzw0YLaTjcRtG0jzy0M6xSv9qebmdBtMPDcacQ,3733
215
215
  tests_integration/test_staging_server_graph.py,sha256=5RfMxjhdX9o4-n_ZRvze4Sm8u8NjRijRLDpqiz8qD_0,7132
216
- helix_fhir_client_sdk-4.2.18.dist-info/METADATA,sha256=pCDGhnLDd-gwFGKTsEffDW5YHvStshWWOsFcEvRRAjc,7210
217
- helix_fhir_client_sdk-4.2.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
218
- helix_fhir_client_sdk-4.2.18.dist-info/top_level.txt,sha256=BRnDS6ceQxs-4u2jXznATObgP8G2cGAerlH0ZS4sJ6M,46
219
- helix_fhir_client_sdk-4.2.18.dist-info/RECORD,,
216
+ helix_fhir_client_sdk-4.2.19.dist-info/METADATA,sha256=AIwal123TuQ7e5KU9XiJ-PO5PWMaKj-YAtcPn0xAgZA,7210
217
+ helix_fhir_client_sdk-4.2.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
218
+ helix_fhir_client_sdk-4.2.19.dist-info/top_level.txt,sha256=BRnDS6ceQxs-4u2jXznATObgP8G2cGAerlH0ZS4sJ6M,46
219
+ helix_fhir_client_sdk-4.2.19.dist-info/RECORD,,