airbyte-cdk 6.37.0.dev1__py3-none-any.whl → 6.37.2__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.
Files changed (45) hide show
  1. airbyte_cdk/connector_builder/models.py +16 -14
  2. airbyte_cdk/connector_builder/test_reader/helpers.py +120 -22
  3. airbyte_cdk/connector_builder/test_reader/message_grouper.py +16 -3
  4. airbyte_cdk/connector_builder/test_reader/types.py +9 -1
  5. airbyte_cdk/sources/declarative/auth/token_provider.py +1 -0
  6. airbyte_cdk/sources/declarative/concurrent_declarative_source.py +43 -7
  7. airbyte_cdk/sources/declarative/datetime/datetime_parser.py +7 -1
  8. airbyte_cdk/sources/declarative/declarative_component_schema.yaml +77 -48
  9. airbyte_cdk/sources/declarative/decoders/composite_raw_decoder.py +13 -2
  10. airbyte_cdk/sources/declarative/extractors/response_to_file_extractor.py +1 -0
  11. airbyte_cdk/sources/declarative/incremental/concurrent_partition_cursor.py +83 -17
  12. airbyte_cdk/sources/declarative/interpolation/macros.py +2 -0
  13. airbyte_cdk/sources/declarative/models/declarative_component_schema.py +37 -50
  14. airbyte_cdk/sources/declarative/parsers/custom_code_compiler.py +18 -4
  15. airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +171 -70
  16. airbyte_cdk/sources/declarative/partition_routers/__init__.py +0 -4
  17. airbyte_cdk/sources/declarative/requesters/README.md +5 -5
  18. airbyte_cdk/sources/declarative/requesters/http_job_repository.py +60 -17
  19. airbyte_cdk/sources/declarative/requesters/http_requester.py +49 -17
  20. airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +25 -4
  21. airbyte_cdk/sources/declarative/requesters/paginators/no_pagination.py +6 -1
  22. airbyte_cdk/sources/declarative/requesters/paginators/paginator.py +7 -2
  23. airbyte_cdk/sources/declarative/requesters/requester.py +7 -1
  24. airbyte_cdk/sources/declarative/retrievers/async_retriever.py +10 -3
  25. airbyte_cdk/sources/declarative/retrievers/simple_retriever.py +21 -4
  26. airbyte_cdk/sources/declarative/transformations/keys_to_snake_transformation.py +2 -2
  27. airbyte_cdk/sources/http_logger.py +3 -0
  28. airbyte_cdk/sources/streams/concurrent/state_converters/abstract_stream_state_converter.py +2 -1
  29. airbyte_cdk/sources/streams/concurrent/state_converters/incrementing_count_stream_state_converter.py +92 -0
  30. airbyte_cdk/sources/streams/http/error_handlers/default_error_mapping.py +3 -3
  31. airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +1 -0
  32. airbyte_cdk/sources/types.py +1 -0
  33. airbyte_cdk/utils/mapping_helpers.py +18 -1
  34. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/METADATA +4 -4
  35. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/RECORD +39 -44
  36. airbyte_cdk/sources/declarative/partition_routers/grouping_partition_router.py +0 -136
  37. airbyte_cdk/sources/embedded/__init__.py +0 -3
  38. airbyte_cdk/sources/embedded/base_integration.py +0 -61
  39. airbyte_cdk/sources/embedded/catalog.py +0 -57
  40. airbyte_cdk/sources/embedded/runner.py +0 -57
  41. airbyte_cdk/sources/embedded/tools.py +0 -27
  42. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/LICENSE.txt +0 -0
  43. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/LICENSE_SHORT +0 -0
  44. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/WHEEL +0 -0
  45. {airbyte_cdk-6.37.0.dev1.dist-info → airbyte_cdk-6.37.2.dist-info}/entry_points.txt +0 -0
@@ -21,20 +21,6 @@ class HttpRequest:
21
21
  body: Optional[str] = None
22
22
 
23
23
 
24
- @dataclass
25
- class StreamReadPages:
26
- records: List[object]
27
- request: Optional[HttpRequest] = None
28
- response: Optional[HttpResponse] = None
29
-
30
-
31
- @dataclass
32
- class StreamReadSlices:
33
- pages: List[StreamReadPages]
34
- slice_descriptor: Optional[Dict[str, Any]]
35
- state: Optional[List[Dict[str, Any]]] = None
36
-
37
-
38
24
  @dataclass
39
25
  class LogMessage:
40
26
  message: str
@@ -46,11 +32,27 @@ class LogMessage:
46
32
  @dataclass
47
33
  class AuxiliaryRequest:
48
34
  title: str
35
+ type: str
49
36
  description: str
50
37
  request: HttpRequest
51
38
  response: HttpResponse
52
39
 
53
40
 
41
+ @dataclass
42
+ class StreamReadPages:
43
+ records: List[object]
44
+ request: Optional[HttpRequest] = None
45
+ response: Optional[HttpResponse] = None
46
+
47
+
48
+ @dataclass
49
+ class StreamReadSlices:
50
+ pages: List[StreamReadPages]
51
+ slice_descriptor: Optional[Dict[str, Any]]
52
+ state: Optional[List[Dict[str, Any]]] = None
53
+ auxiliary_requests: Optional[List[AuxiliaryRequest]] = None
54
+
55
+
54
56
  @dataclass
55
57
  class StreamRead(object):
56
58
  logs: List[LogMessage]
@@ -28,7 +28,7 @@ from airbyte_cdk.utils.schema_inferrer import (
28
28
  SchemaInferrer,
29
29
  )
30
30
 
31
- from .types import LOG_MESSAGES_OUTPUT_TYPE
31
+ from .types import ASYNC_AUXILIARY_REQUEST_TYPES, LOG_MESSAGES_OUTPUT_TYPE
32
32
 
33
33
  # -------
34
34
  # Parsers
@@ -226,7 +226,8 @@ def should_close_page(
226
226
  at_least_one_page_in_group
227
227
  and is_log_message(message)
228
228
  and (
229
- is_page_http_request(json_message) or message.log.message.startswith("slice:") # type: ignore[union-attr] # AirbyteMessage with MessageType.LOG has log.message
229
+ is_page_http_request(json_message)
230
+ or message.log.message.startswith(SliceLogger.SLICE_LOG_PREFIX) # type: ignore[union-attr] # AirbyteMessage with MessageType.LOG has log.message
230
231
  )
231
232
  )
232
233
 
@@ -330,6 +331,10 @@ def is_auxiliary_http_request(message: Optional[Dict[str, Any]]) -> bool:
330
331
  return is_http_log(message) and message.get("http", {}).get("is_auxiliary", False)
331
332
 
332
333
 
334
+ def is_async_auxiliary_request(message: AuxiliaryRequest) -> bool:
335
+ return message.type in ASYNC_AUXILIARY_REQUEST_TYPES
336
+
337
+
333
338
  def is_log_message(message: AirbyteMessage) -> bool:
334
339
  """
335
340
  Determines whether the provided message is of type LOG.
@@ -413,6 +418,7 @@ def handle_current_slice(
413
418
  current_slice_pages: List[StreamReadPages],
414
419
  current_slice_descriptor: Optional[Dict[str, Any]] = None,
415
420
  latest_state_message: Optional[Dict[str, Any]] = None,
421
+ auxiliary_requests: Optional[List[AuxiliaryRequest]] = None,
416
422
  ) -> StreamReadSlices:
417
423
  """
418
424
  Handles the current slice by packaging its pages, descriptor, and state into a StreamReadSlices instance.
@@ -421,6 +427,7 @@ def handle_current_slice(
421
427
  current_slice_pages (List[StreamReadPages]): The pages to be included in the slice.
422
428
  current_slice_descriptor (Optional[Dict[str, Any]]): Descriptor for the current slice, optional.
423
429
  latest_state_message (Optional[Dict[str, Any]]): The latest state message, optional.
430
+ auxiliary_requests (Optional[List[AuxiliaryRequest]]): The auxiliary requests to include, optional.
424
431
 
425
432
  Returns:
426
433
  StreamReadSlices: An object containing the current slice's pages, descriptor, and state.
@@ -429,6 +436,7 @@ def handle_current_slice(
429
436
  pages=current_slice_pages,
430
437
  slice_descriptor=current_slice_descriptor,
431
438
  state=[latest_state_message] if latest_state_message else [],
439
+ auxiliary_requests=auxiliary_requests if auxiliary_requests else [],
432
440
  )
433
441
 
434
442
 
@@ -486,29 +494,24 @@ def handle_auxiliary_request(json_message: Dict[str, JsonType]) -> AuxiliaryRequ
486
494
  Raises:
487
495
  ValueError: If any of the "airbyte_cdk", "stream", or "http" fields is not a dictionary.
488
496
  """
489
- airbyte_cdk = json_message.get("airbyte_cdk", {})
490
-
491
- if not isinstance(airbyte_cdk, dict):
492
- raise ValueError(
493
- f"Expected airbyte_cdk to be a dict, got {airbyte_cdk} of type {type(airbyte_cdk)}"
494
- )
495
-
496
- stream = airbyte_cdk.get("stream", {})
497
497
 
498
- if not isinstance(stream, dict):
499
- raise ValueError(f"Expected stream to be a dict, got {stream} of type {type(stream)}")
498
+ airbyte_cdk = get_airbyte_cdk_from_message(json_message)
499
+ stream = get_stream_from_airbyte_cdk(airbyte_cdk)
500
+ title_prefix = get_auxiliary_request_title_prefix(stream)
501
+ http = get_http_property_from_message(json_message)
502
+ request_type = get_auxiliary_request_type(stream, http)
500
503
 
501
- title_prefix = "Parent stream: " if stream.get("is_substream", False) else ""
502
- http = json_message.get("http", {})
503
-
504
- if not isinstance(http, dict):
505
- raise ValueError(f"Expected http to be a dict, got {http} of type {type(http)}")
504
+ title = title_prefix + str(http.get("title", None))
505
+ description = str(http.get("description", None))
506
+ request = create_request_from_log_message(json_message)
507
+ response = create_response_from_log_message(json_message)
506
508
 
507
509
  return AuxiliaryRequest(
508
- title=title_prefix + str(http.get("title", None)),
509
- description=str(http.get("description", None)),
510
- request=create_request_from_log_message(json_message),
511
- response=create_response_from_log_message(json_message),
510
+ title=title,
511
+ type=request_type,
512
+ description=description,
513
+ request=request,
514
+ response=response,
512
515
  )
513
516
 
514
517
 
@@ -558,7 +561,8 @@ def handle_log_message(
558
561
  at_least_one_page_in_group,
559
562
  current_page_request,
560
563
  current_page_response,
561
- auxiliary_request or log_message,
564
+ auxiliary_request,
565
+ log_message,
562
566
  )
563
567
 
564
568
 
@@ -589,3 +593,97 @@ def handle_record_message(
589
593
  datetime_format_inferrer.accumulate(message.record) # type: ignore
590
594
 
591
595
  return records_count
596
+
597
+
598
+ # -------
599
+ # Reusable Getters
600
+ # -------
601
+
602
+
603
+ def get_airbyte_cdk_from_message(json_message: Dict[str, JsonType]) -> dict: # type: ignore
604
+ """
605
+ Retrieves the "airbyte_cdk" dictionary from the provided JSON message.
606
+
607
+ This function validates that the extracted "airbyte_cdk" is of type dict,
608
+ raising a ValueError if the validation fails.
609
+
610
+ Parameters:
611
+ json_message (Dict[str, JsonType]): A dictionary representing the JSON message.
612
+
613
+ Returns:
614
+ dict: The "airbyte_cdk" dictionary extracted from the JSON message.
615
+
616
+ Raises:
617
+ ValueError: If the "airbyte_cdk" field is not a dictionary.
618
+ """
619
+ airbyte_cdk = json_message.get("airbyte_cdk", {})
620
+
621
+ if not isinstance(airbyte_cdk, dict):
622
+ raise ValueError(
623
+ f"Expected airbyte_cdk to be a dict, got {airbyte_cdk} of type {type(airbyte_cdk)}"
624
+ )
625
+
626
+ return airbyte_cdk
627
+
628
+
629
+ def get_stream_from_airbyte_cdk(airbyte_cdk: dict) -> dict: # type: ignore
630
+ """
631
+ Retrieves the "stream" dictionary from the provided "airbyte_cdk" dictionary.
632
+
633
+ This function ensures that the extracted "stream" is of type dict,
634
+ raising a ValueError if the validation fails.
635
+
636
+ Parameters:
637
+ airbyte_cdk (dict): The dictionary representing the Airbyte CDK data.
638
+
639
+ Returns:
640
+ dict: The "stream" dictionary extracted from the Airbyte CDK data.
641
+
642
+ Raises:
643
+ ValueError: If the "stream" field is not a dictionary.
644
+ """
645
+
646
+ stream = airbyte_cdk.get("stream", {})
647
+
648
+ if not isinstance(stream, dict):
649
+ raise ValueError(f"Expected stream to be a dict, got {stream} of type {type(stream)}")
650
+
651
+ return stream
652
+
653
+
654
+ def get_auxiliary_request_title_prefix(stream: dict) -> str: # type: ignore
655
+ """
656
+ Generates a title prefix based on the stream type.
657
+ """
658
+ return "Parent stream: " if stream.get("is_substream", False) else ""
659
+
660
+
661
+ def get_http_property_from_message(json_message: Dict[str, JsonType]) -> dict: # type: ignore
662
+ """
663
+ Retrieves the "http" dictionary from the provided JSON message.
664
+
665
+ This function validates that the extracted "http" is of type dict,
666
+ raising a ValueError if the validation fails.
667
+
668
+ Parameters:
669
+ json_message (Dict[str, JsonType]): A dictionary representing the JSON message.
670
+
671
+ Returns:
672
+ dict: The "http" dictionary extracted from the JSON message.
673
+
674
+ Raises:
675
+ ValueError: If the "http" field is not a dictionary.
676
+ """
677
+ http = json_message.get("http", {})
678
+
679
+ if not isinstance(http, dict):
680
+ raise ValueError(f"Expected http to be a dict, got {http} of type {type(http)}")
681
+
682
+ return http
683
+
684
+
685
+ def get_auxiliary_request_type(stream: dict, http: dict) -> str: # type: ignore
686
+ """
687
+ Determines the type of the auxiliary request based on the stream and HTTP properties.
688
+ """
689
+ return "PARENT_STREAM" if stream.get("is_substream", False) else str(http.get("type", None))
@@ -6,6 +6,7 @@
6
6
  from typing import Any, Dict, Iterator, List, Mapping, Optional
7
7
 
8
8
  from airbyte_cdk.connector_builder.models import (
9
+ AuxiliaryRequest,
9
10
  HttpRequest,
10
11
  HttpResponse,
11
12
  StreamReadPages,
@@ -24,6 +25,7 @@ from .helpers import (
24
25
  handle_current_slice,
25
26
  handle_log_message,
26
27
  handle_record_message,
28
+ is_async_auxiliary_request,
27
29
  is_config_update_message,
28
30
  is_log_message,
29
31
  is_record_message,
@@ -89,6 +91,7 @@ def get_message_groups(
89
91
  current_page_request: Optional[HttpRequest] = None
90
92
  current_page_response: Optional[HttpResponse] = None
91
93
  latest_state_message: Optional[Dict[str, Any]] = None
94
+ slice_auxiliary_requests: List[AuxiliaryRequest] = []
92
95
 
93
96
  while records_count < limit and (message := next(messages, None)):
94
97
  json_message = airbyte_message_to_json(message)
@@ -106,6 +109,7 @@ def get_message_groups(
106
109
  current_slice_pages,
107
110
  current_slice_descriptor,
108
111
  latest_state_message,
112
+ slice_auxiliary_requests,
109
113
  )
110
114
  current_slice_descriptor = parse_slice_description(message.log.message) # type: ignore
111
115
  current_slice_pages = []
@@ -118,7 +122,8 @@ def get_message_groups(
118
122
  at_least_one_page_in_group,
119
123
  current_page_request,
120
124
  current_page_response,
121
- log_or_auxiliary_request,
125
+ auxiliary_request,
126
+ log_message,
122
127
  ) = handle_log_message(
123
128
  message,
124
129
  json_message,
@@ -126,8 +131,15 @@ def get_message_groups(
126
131
  current_page_request,
127
132
  current_page_response,
128
133
  )
129
- if log_or_auxiliary_request:
130
- yield log_or_auxiliary_request
134
+
135
+ if auxiliary_request:
136
+ if is_async_auxiliary_request(auxiliary_request):
137
+ slice_auxiliary_requests.append(auxiliary_request)
138
+ else:
139
+ yield auxiliary_request
140
+
141
+ if log_message:
142
+ yield log_message
131
143
  elif is_trace_with_error(message):
132
144
  if message.trace is not None:
133
145
  yield message.trace
@@ -157,4 +169,5 @@ def get_message_groups(
157
169
  current_slice_pages,
158
170
  current_slice_descriptor,
159
171
  latest_state_message,
172
+ slice_auxiliary_requests,
160
173
  )
@@ -71,5 +71,13 @@ LOG_MESSAGES_OUTPUT_TYPE = tuple[
71
71
  bool,
72
72
  HttpRequest | None,
73
73
  HttpResponse | None,
74
- AuxiliaryRequest | AirbyteLogMessage | None,
74
+ AuxiliaryRequest | None,
75
+ AirbyteLogMessage | None,
76
+ ]
77
+
78
+ ASYNC_AUXILIARY_REQUEST_TYPES = [
79
+ "ASYNC_CREATE",
80
+ "ASYNC_POLL",
81
+ "ASYNC_ABORT",
82
+ "ASYNC_DELETE",
75
83
  ]
@@ -58,6 +58,7 @@ class SessionTokenProvider(TokenProvider):
58
58
  "Obtains session token",
59
59
  None,
60
60
  is_auxiliary=True,
61
+ type="AUTH",
61
62
  ),
62
63
  )
63
64
  if response is None:
@@ -31,6 +31,9 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
31
31
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
32
32
  DatetimeBasedCursor as DatetimeBasedCursorModel,
33
33
  )
34
+ from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
35
+ IncrementingCountCursor as IncrementingCountCursorModel,
36
+ )
34
37
  from airbyte_cdk.sources.declarative.parsers.model_to_component_factory import (
35
38
  ModelToComponentFactory,
36
39
  )
@@ -44,6 +47,7 @@ from airbyte_cdk.sources.declarative.types import ConnectionDefinition
44
47
  from airbyte_cdk.sources.source import TState
45
48
  from airbyte_cdk.sources.streams import Stream
46
49
  from airbyte_cdk.sources.streams.concurrent.abstract_stream import AbstractStream
50
+ from airbyte_cdk.sources.streams.concurrent.abstract_stream_facade import AbstractStreamFacade
47
51
  from airbyte_cdk.sources.streams.concurrent.availability_strategy import (
48
52
  AlwaysAvailableAvailabilityStrategy,
49
53
  )
@@ -118,6 +122,12 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
118
122
  message_repository=self.message_repository,
119
123
  )
120
124
 
125
+ # TODO: Remove this. This property is necessary to safely migrate Stripe during the transition state.
126
+ @property
127
+ def is_partially_declarative(self) -> bool:
128
+ """This flag used to avoid unexpected AbstractStreamFacade processing as concurrent streams."""
129
+ return False
130
+
121
131
  def read(
122
132
  self,
123
133
  logger: logging.Logger,
@@ -215,7 +225,7 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
215
225
  and not incremental_sync_component_definition
216
226
  )
217
227
 
218
- if self._is_datetime_incremental_without_partition_routing(
228
+ if self._is_concurrent_cursor_incremental_without_partition_routing(
219
229
  declarative_stream, incremental_sync_component_definition
220
230
  ):
221
231
  stream_state = self._connector_state_manager.get_stream_state(
@@ -247,15 +257,26 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
247
257
  stream_slicer=declarative_stream.retriever.stream_slicer,
248
258
  )
249
259
  else:
250
- cursor = (
251
- self._constructor.create_concurrent_cursor_from_datetime_based_cursor(
260
+ if (
261
+ incremental_sync_component_definition
262
+ and incremental_sync_component_definition.get("type")
263
+ == IncrementingCountCursorModel.__name__
264
+ ):
265
+ cursor = self._constructor.create_concurrent_cursor_from_incrementing_count_cursor(
266
+ model_type=IncrementingCountCursorModel,
267
+ component_definition=incremental_sync_component_definition, # type: ignore # Not None because of the if condition above
268
+ stream_name=declarative_stream.name,
269
+ stream_namespace=declarative_stream.namespace,
270
+ config=config or {},
271
+ )
272
+ else:
273
+ cursor = self._constructor.create_concurrent_cursor_from_datetime_based_cursor(
252
274
  model_type=DatetimeBasedCursorModel,
253
275
  component_definition=incremental_sync_component_definition, # type: ignore # Not None because of the if condition above
254
276
  stream_name=declarative_stream.name,
255
277
  stream_namespace=declarative_stream.namespace,
256
278
  config=config or {},
257
279
  )
258
- )
259
280
  partition_generator = StreamSlicerPartitionGenerator(
260
281
  partition_factory=DeclarativePartitionFactory(
261
282
  declarative_stream.name,
@@ -369,12 +390,20 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
369
390
  )
370
391
  else:
371
392
  synchronous_streams.append(declarative_stream)
393
+ # TODO: Remove this. This check is necessary to safely migrate Stripe during the transition state.
394
+ # Condition below needs to ensure that concurrent support is not lost for sources that already support
395
+ # it before migration, but now are only partially migrated to declarative implementation (e.g., Stripe).
396
+ elif (
397
+ isinstance(declarative_stream, AbstractStreamFacade)
398
+ and self.is_partially_declarative
399
+ ):
400
+ concurrent_streams.append(declarative_stream.get_underlying_stream())
372
401
  else:
373
402
  synchronous_streams.append(declarative_stream)
374
403
 
375
404
  return concurrent_streams, synchronous_streams
376
405
 
377
- def _is_datetime_incremental_without_partition_routing(
406
+ def _is_concurrent_cursor_incremental_without_partition_routing(
378
407
  self,
379
408
  declarative_stream: DeclarativeStream,
380
409
  incremental_sync_component_definition: Mapping[str, Any] | None,
@@ -382,11 +411,18 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
382
411
  return (
383
412
  incremental_sync_component_definition is not None
384
413
  and bool(incremental_sync_component_definition)
385
- and incremental_sync_component_definition.get("type", "")
386
- == DatetimeBasedCursorModel.__name__
414
+ and (
415
+ incremental_sync_component_definition.get("type", "")
416
+ in (DatetimeBasedCursorModel.__name__, IncrementingCountCursorModel.__name__)
417
+ )
387
418
  and hasattr(declarative_stream.retriever, "stream_slicer")
388
419
  and (
389
420
  isinstance(declarative_stream.retriever.stream_slicer, DatetimeBasedCursor)
421
+ # IncrementingCountCursorModel is hardcoded to be of type DatetimeBasedCursor
422
+ # add isintance check here if we want to create a Declarative IncrementingCountCursor
423
+ # or isinstance(
424
+ # declarative_stream.retriever.stream_slicer, IncrementingCountCursor
425
+ # )
390
426
  or isinstance(declarative_stream.retriever.stream_slicer, AsyncJobPartitionRouter)
391
427
  )
392
428
  )
@@ -31,7 +31,8 @@ class DatetimeParser:
31
31
  return datetime.datetime.fromtimestamp(float(date), tz=datetime.timezone.utc)
32
32
  elif format == "%ms":
33
33
  return self._UNIX_EPOCH + datetime.timedelta(milliseconds=int(date))
34
-
34
+ elif "%_ms" in format:
35
+ format = format.replace("%_ms", "%f")
35
36
  parsed_datetime = datetime.datetime.strptime(str(date), format)
36
37
  if self._is_naive(parsed_datetime):
37
38
  return parsed_datetime.replace(tzinfo=datetime.timezone.utc)
@@ -48,6 +49,11 @@ class DatetimeParser:
48
49
  if format == "%ms":
49
50
  # timstamp() returns a float representing the number of seconds since the unix epoch
50
51
  return str(int(dt.timestamp() * 1000))
52
+ if "%_ms" in format:
53
+ _format = format.replace("%_ms", "%f")
54
+ milliseconds = int(dt.microsecond / 1000)
55
+ formatted_dt = dt.strftime(_format).replace(dt.strftime("%f"), "%03d" % milliseconds)
56
+ return formatted_dt
51
57
  else:
52
58
  return dt.strftime(format)
53
59