103103import time
104104from collections .abc import Iterator , Mapping
105105from dataclasses import asdict , dataclass
106- from datetime import datetime
107- from typing import TYPE_CHECKING , Any , final
106+ from typing import TYPE_CHECKING , Any
107+
108+ from typing_extensions import final
109+
110+ from airbyte_cdk .utils .datetime_helpers import ab_datetime_parse
108111
109112from airbyte ._util import api_util
110113from airbyte .cloud .constants import FAILED_STATUSES , FINAL_STATUSES
117120"""The default timeout for waiting for a sync job to complete, in seconds."""
118121
119122if TYPE_CHECKING :
123+ from datetime import datetime
124+
120125 import sqlalchemy
121126
122127 from airbyte ._util .api_imports import ConnectionResponse , JobResponse , JobStatusEnum
125130 from airbyte .cloud .workspaces import CloudWorkspace
126131
127132
133+ @dataclass
134+ class SyncAttempt :
135+ """Represents a single attempt of a sync job.
136+
137+ **This class is not meant to be instantiated directly.** Instead, obtain a `SyncAttempt` by
138+ calling `.SyncResult.get_attempts()`.
139+ """
140+
141+ workspace : CloudWorkspace
142+ connection : CloudConnection
143+ job_id : int
144+ attempt_number : int
145+ _attempt_data : dict [str , Any ] | None = None
146+
147+ @property
148+ def attempt_id (self ) -> int :
149+ """Return the attempt ID."""
150+ return self ._get_attempt_data ()["id" ]
151+
152+ @property
153+ def status (self ) -> str :
154+ """Return the attempt status."""
155+ return self ._get_attempt_data ()["status" ]
156+
157+ @property
158+ def bytes_synced (self ) -> int :
159+ """Return the number of bytes synced in this attempt."""
160+ return self ._get_attempt_data ().get ("bytesSynced" , 0 )
161+
162+ @property
163+ def records_synced (self ) -> int :
164+ """Return the number of records synced in this attempt."""
165+ return self ._get_attempt_data ().get ("recordsSynced" , 0 )
166+
167+ @property
168+ def created_at (self ) -> datetime :
169+ """Return the creation time of the attempt."""
170+ timestamp = self ._get_attempt_data ()["createdAt" ]
171+ return ab_datetime_parse (timestamp )
172+
173+ def _get_attempt_data (self ) -> dict [str , Any ]:
174+ """Get attempt data from the provided attempt data."""
175+ if self ._attempt_data is None :
176+ raise ValueError (
177+ "Attempt data not provided. SyncAttempt should be created via "
178+ "SyncResult.get_attempts()."
179+ )
180+ return self ._attempt_data ["attempt" ]
181+
182+ def get_full_log_text (self ) -> str :
183+ """Return the complete log text for this attempt.
184+
185+ Returns:
186+ String containing all log text for this attempt, with lines separated by newlines.
187+ """
188+ if self ._attempt_data is None :
189+ return ""
190+
191+ logs_data = self ._attempt_data .get ("logs" )
192+ if not logs_data :
193+ return ""
194+
195+ result = ""
196+
197+ if "events" in logs_data :
198+ log_events = logs_data ["events" ]
199+ if log_events :
200+ log_lines = []
201+ for event in log_events :
202+ timestamp = event .get ("timestamp" , "" )
203+ level = event .get ("level" , "INFO" )
204+ message = event .get ("message" , "" )
205+ log_lines .append (f"[{ timestamp } ] { level } : { message } " )
206+ result = "\n " .join (log_lines )
207+ elif "logLines" in logs_data :
208+ log_lines = logs_data ["logLines" ]
209+ if log_lines :
210+ result = "\n " .join (log_lines )
211+
212+ return result
213+
214+
128215@dataclass
129216class SyncResult :
130217 """The result of a sync operation.
@@ -141,6 +228,7 @@ class SyncResult:
141228 _latest_job_info : JobResponse | None = None
142229 _connection_response : ConnectionResponse | None = None
143230 _cache : CacheBase | None = None
231+ _job_with_attempts_info : dict [str , Any ] | None = None
144232
145233 @property
146234 def job_url (self ) -> str :
@@ -213,8 +301,53 @@ def records_synced(self) -> int:
213301 @property
214302 def start_time (self ) -> datetime :
215303 """Return the start time of the sync job in UTC."""
216- # Parse from ISO 8601 format:
217- return datetime .fromisoformat (self ._fetch_latest_job_info ().start_time )
304+ try :
305+ return ab_datetime_parse (self ._fetch_latest_job_info ().start_time )
306+ except (ValueError , TypeError ) as e :
307+ if "Invalid isoformat string" in str (e ):
308+ job_info_raw = api_util ._make_config_api_request ( # noqa: SLF001
309+ api_root = self .workspace .api_root ,
310+ path = "/jobs/get" ,
311+ json = {"id" : self .job_id },
312+ client_id = self .workspace .client_id ,
313+ client_secret = self .workspace .client_secret ,
314+ )
315+ raw_start_time = job_info_raw .get ("startTime" )
316+ if raw_start_time :
317+ return ab_datetime_parse (raw_start_time )
318+ raise
319+
320+ def _fetch_job_with_attempts (self ) -> dict [str , Any ]:
321+ """Fetch job info with attempts from Config API using lazy loading pattern."""
322+ if self ._job_with_attempts_info is not None :
323+ return self ._job_with_attempts_info
324+
325+ self ._job_with_attempts_info = api_util ._make_config_api_request ( # noqa: SLF001 # Config API helper
326+ api_root = self .workspace .api_root ,
327+ path = "/jobs/get" ,
328+ json = {
329+ "id" : self .job_id ,
330+ },
331+ client_id = self .workspace .client_id ,
332+ client_secret = self .workspace .client_secret ,
333+ )
334+ return self ._job_with_attempts_info
335+
336+ def get_attempts (self ) -> list [SyncAttempt ]:
337+ """Return a list of attempts for this sync job."""
338+ job_with_attempts = self ._fetch_job_with_attempts ()
339+ attempts_data = job_with_attempts .get ("attempts" , [])
340+
341+ return [
342+ SyncAttempt (
343+ workspace = self .workspace ,
344+ connection = self .connection ,
345+ job_id = self .job_id ,
346+ attempt_number = i ,
347+ _attempt_data = attempt_data ,
348+ )
349+ for i , attempt_data in enumerate (attempts_data , start = 0 )
350+ ]
218351
219352 def raise_failure_status (
220353 self ,
@@ -362,4 +495,5 @@ def __len__(self) -> int:
362495
363496__all__ = [
364497 "SyncResult" ,
498+ "SyncAttempt" ,
365499]
0 commit comments