Skip to content

Commit 1ced45b

Browse files
committed
Status message on fail
1 parent 401094f commit 1ced45b

13 files changed

Lines changed: 640 additions & 238 deletions

dapi/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
"AuthenticationError",
4545
"FileOperationError",
4646
"AppDiscoveryError",
47-
"SystemInfoError"
48-
"JobSubmissionError",
47+
"SystemInfoError" "JobSubmissionError",
4948
"JobMonitorError",
5049
]

dapi/client.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from . import apps as apps_module
55
from . import files as files_module
66
from . import jobs as jobs_module
7-
from . import systems as systems_module
7+
from . import systems as systems_module
88
from .db.accessor import DatabaseAccessor
99

1010
# Import only the necessary classes/functions from jobs
@@ -65,14 +65,18 @@ def download(self, *args, **kwargs):
6565
def list(self, *args, **kwargs) -> List[Tapis]:
6666
return files_module.list_files(self._tapis, *args, **kwargs)
6767

68+
6869
class SystemMethods:
6970
def __init__(self, tapis_client: Tapis):
7071
self._tapis = tapis_client
7172

7273
def list_queues(self, system_id: str, verbose: bool = True) -> List[Any]:
7374
"""Lists logical queues for a given Tapis system."""
74-
return systems_module.list_system_queues(self._tapis, system_id, verbose=verbose)
75-
75+
return systems_module.list_system_queues(
76+
self._tapis, system_id, verbose=verbose
77+
)
78+
79+
7680
class JobMethods:
7781
def __init__(self, tapis_client: Tapis):
7882
self._tapis = tapis_client

dapi/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ class AppDiscoveryError(DapiException):
2424

2525
pass
2626

27+
2728
class SystemInfoError(DapiException):
2829
"""Error retrieving information about Tapis systems or queues."""
30+
2931
pass
3032

3133

dapi/files.py

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def get_ds_path_uri(t: Tapis, path: str, verify_exists: bool = False) -> str:
6161
current_username = getattr(t, "username", None)
6262
# ---
6363

64-
input_uri = None # Initialize variable
64+
input_uri = None # Initialize variable
6565

6666
# 1. Handle MyData variations
6767
mydata_patterns = [
@@ -83,13 +83,17 @@ def get_ds_path_uri(t: Tapis, path: str, verify_exists: bool = False) -> str:
8383
)
8484
path_remainder = path.split(pattern, 1)[1].lstrip("/")
8585
if use_username:
86-
tapis_path = f"{current_username}/{path_remainder}" if path_remainder else current_username
86+
tapis_path = (
87+
f"{current_username}/{path_remainder}"
88+
if path_remainder
89+
else current_username
90+
)
8791
else:
8892
tapis_path = path_remainder
8993
encoded_path = urllib.parse.quote(tapis_path)
9094
input_uri = f"tapis://{storage_system_id}/{encoded_path}"
9195
print(f"Translated '{path}' to '{input_uri}' using t.username")
92-
break # Found match, exit loop
96+
break # Found match, exit loop
9397

9498
# 2. Handle Community variations (if not already matched)
9599
if input_uri is None:
@@ -105,7 +109,7 @@ def get_ds_path_uri(t: Tapis, path: str, verify_exists: bool = False) -> str:
105109
encoded_path = urllib.parse.quote(tapis_path)
106110
input_uri = f"tapis://{storage_system_id}/{encoded_path}"
107111
print(f"Translated '{path}' to '{input_uri}'")
108-
break # Found match, exit loop
112+
break # Found match, exit loop
109113

110114
# 3. Handle Project variations (if not already matched)
111115
if input_uri is None:
@@ -120,51 +124,81 @@ def get_ds_path_uri(t: Tapis, path: str, verify_exists: bool = False) -> str:
120124
if pattern in path:
121125
path_remainder_full = path.split(pattern, 1)[1].lstrip("/")
122126
if not path_remainder_full:
123-
raise ValueError(f"Project path '{path}' is incomplete. Missing project ID.")
127+
raise ValueError(
128+
f"Project path '{path}' is incomplete. Missing project ID."
129+
)
124130
parts = path_remainder_full.split("/", 1)
125131
project_id_part = parts[0]
126132
path_within_project = parts[1] if len(parts) > 1 else ""
127133

128134
print(f"Searching Tapis systems for project ID '{project_id_part}'...")
129135
found_system_id = None
130136
try:
131-
search_query = f"description.like.%{project_id_part}%&id.like.{system_prefix}*"
132-
systems = t.systems.getSystems(search=search_query, listType="ALL", select="id,owner,description", limit=10)
137+
search_query = (
138+
f"description.like.%{project_id_part}%&id.like.{system_prefix}*"
139+
)
140+
systems = t.systems.getSystems(
141+
search=search_query,
142+
listType="ALL",
143+
select="id,owner,description",
144+
limit=10,
145+
)
133146
matches = []
134147
if systems:
135148
for sys in systems:
136-
if project_id_part.lower() in getattr(sys, "description", "").lower():
149+
if (
150+
project_id_part.lower()
151+
in getattr(sys, "description", "").lower()
152+
):
137153
matches.append(sys.id)
138154
if len(matches) == 1:
139155
found_system_id = matches[0]
140156
print(f"Found unique matching system: {found_system_id}")
141157
elif len(matches) == 0:
142158
if "-" in project_id_part and len(project_id_part) > 30:
143159
potential_sys_id = f"{system_prefix}{project_id_part}"
144-
print(f"Search failed, attempting direct lookup for system ID: {potential_sys_id}")
160+
print(
161+
f"Search failed, attempting direct lookup for system ID: {potential_sys_id}"
162+
)
145163
try:
146-
t.systems.getSystem(systemId=potential_sys_id, select="id") # Select minimal field
164+
t.systems.getSystem(
165+
systemId=potential_sys_id, select="id"
166+
) # Select minimal field
147167
found_system_id = potential_sys_id
148168
print(f"Direct lookup successful: {found_system_id}")
149169
except BaseTapyException:
150-
print(f"Direct lookup for {potential_sys_id} also failed.")
151-
raise FileOperationError(f"No project system found matching ID '{project_id_part}' via Tapis v3 search or direct UUID lookup.")
170+
print(
171+
f"Direct lookup for {potential_sys_id} also failed."
172+
)
173+
raise FileOperationError(
174+
f"No project system found matching ID '{project_id_part}' via Tapis v3 search or direct UUID lookup."
175+
)
152176
else:
153-
raise FileOperationError(f"No project system found matching ID '{project_id_part}' via Tapis v3 search.")
177+
raise FileOperationError(
178+
f"No project system found matching ID '{project_id_part}' via Tapis v3 search."
179+
)
154180
else:
155-
raise FileOperationError(f"Multiple project systems found potentially matching ID '{project_id_part}': {matches}. Cannot determine unique system.")
181+
raise FileOperationError(
182+
f"Multiple project systems found potentially matching ID '{project_id_part}': {matches}. Cannot determine unique system."
183+
)
156184
except BaseTapyException as e:
157-
raise FileOperationError(f"Tapis API error searching for project system '{project_id_part}': {e}") from e
185+
raise FileOperationError(
186+
f"Tapis API error searching for project system '{project_id_part}': {e}"
187+
) from e
158188
except Exception as e:
159-
raise FileOperationError(f"Unexpected error searching for project system '{project_id_part}': {e}") from e
189+
raise FileOperationError(
190+
f"Unexpected error searching for project system '{project_id_part}': {e}"
191+
) from e
160192

161193
if not found_system_id:
162-
raise FileOperationError(f"Could not resolve project ID '{project_id_part}' to a Tapis system ID.")
194+
raise FileOperationError(
195+
f"Could not resolve project ID '{project_id_part}' to a Tapis system ID."
196+
)
163197

164198
encoded_path_within_project = urllib.parse.quote(path_within_project)
165199
input_uri = f"tapis://{found_system_id}/{encoded_path_within_project}"
166200
print(f"Translated '{path}' to '{input_uri}' using Tapis v3 lookup")
167-
break # Found match, exit loop
201+
break # Found match, exit loop
168202

169203
# 4. Handle direct tapis:// URI input (if not already matched)
170204
if input_uri is None and path.startswith("tapis://"):
@@ -194,16 +228,26 @@ def get_ds_path_uri(t: Tapis, path: str, verify_exists: bool = False) -> str:
194228
print(f"Verification successful: Path exists.")
195229
except BaseTapyException as e:
196230
# Specifically check for 404 on the listFiles call
197-
if hasattr(e, 'response') and e.response and e.response.status_code == 404:
198-
raise FileOperationError(f"Verification failed: Path '{decoded_remote_path}' does not exist on system '{system_id}'. Translated URI: {input_uri}") from e
231+
if hasattr(e, "response") and e.response and e.response.status_code == 404:
232+
raise FileOperationError(
233+
f"Verification failed: Path '{decoded_remote_path}' does not exist on system '{system_id}'. Translated URI: {input_uri}"
234+
) from e
199235
else:
200236
# Re-raise other Tapis errors encountered during verification
201-
raise FileOperationError(f"Verification error for path '{decoded_remote_path}' on system '{system_id}': {e}") from e
202-
except ValueError as e: # Catch errors from _parse_tapis_uri if input_uri was bad
203-
raise FileOperationError(f"Verification failed: Could not parse translated URI '{input_uri}' for verification. Error: {e}") from e
237+
raise FileOperationError(
238+
f"Verification error for path '{decoded_remote_path}' on system '{system_id}': {e}"
239+
) from e
240+
except (
241+
ValueError
242+
) as e: # Catch errors from _parse_tapis_uri if input_uri was bad
243+
raise FileOperationError(
244+
f"Verification failed: Could not parse translated URI '{input_uri}' for verification. Error: {e}"
245+
) from e
204246
except Exception as e:
205-
# Catch other unexpected errors during verification
206-
raise FileOperationError(f"Unexpected verification error for path at '{input_uri}': {e}") from e
247+
# Catch other unexpected errors during verification
248+
raise FileOperationError(
249+
f"Unexpected verification error for path at '{input_uri}': {e}"
250+
) from e
207251

208252
return input_uri
209253

dapi/jobs.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,31 @@ def get_status(self, force_refresh: bool = True) -> str:
299299
f"Failed to get status for job {self.uuid}: {e}"
300300
) from e
301301

302+
@property
303+
def last_message(self) -> Optional[str]:
304+
"""
305+
Retrieves the last status message recorded for the job.
306+
This is typically found in the 'lastMessage' attribute of the job details.
307+
Returns None if the message is not available or not set.
308+
"""
309+
try:
310+
details = self.details # Ensures job details are loaded
311+
message = getattr(details, "lastMessage", None)
312+
if message:
313+
# Sometimes messages might be empty strings, treat as None for consistency
314+
return str(message).strip() if str(message).strip() else None
315+
return None
316+
except JobMonitorError as e:
317+
print(
318+
f"Could not retrieve job details to get last_message for job {self.uuid}: {e}"
319+
)
320+
return None
321+
except Exception as e:
322+
print(
323+
f"An unexpected error occurred while fetching last_message for job {self.uuid}: {e}"
324+
)
325+
return None
326+
302327
def monitor(self, interval: int = 15, timeout_minutes: Optional[int] = None) -> str:
303328
"""
304329
Monitors the job status with tqdm progress bars until completion,
@@ -576,6 +601,97 @@ def download_output(self, remote_path: str, local_target: str):
576601
f"Failed to download output '{remote_path}' for job {self.uuid}: {e}"
577602
) from e
578603

604+
def get_output_content(
605+
self,
606+
output_filename: str,
607+
max_lines: Optional[int] = None,
608+
missing_ok: bool = True,
609+
) -> Optional[str]:
610+
"""
611+
Retrieves the content of a specific output file from the job's archive.
612+
613+
Args:
614+
output_filename: The name of the file in the job's archive root
615+
(e.g., "tapisjob.out", "tapisjob.err").
616+
max_lines: If specified, returns only the last `max_lines` of the file.
617+
missing_ok: If True and the file is not found, returns None.
618+
If False and not found, raises FileOperationError.
619+
620+
Returns:
621+
The content of the file as a string, or None if not found (and missing_ok=True).
622+
623+
Raises:
624+
FileOperationError: If the job archive is not available, the file is not found (and missing_ok=False),
625+
or if there's an error fetching the file.
626+
"""
627+
print(f"Attempting to fetch content of '{output_filename}' from job archive...")
628+
details = self._get_details() # Ensure details are loaded
629+
if not details.archiveSystemId or not details.archiveSystemDir:
630+
raise FileOperationError(
631+
f"Job {self.uuid} archive system ID or directory not available. Cannot fetch output."
632+
)
633+
634+
full_archive_path = os.path.join(
635+
details.archiveSystemDir, output_filename.lstrip("/")
636+
)
637+
full_archive_path = os.path.normpath(full_archive_path).lstrip("/")
638+
639+
try:
640+
# self._tapis.files.getContents() is expected to return the full file content as bytes
641+
# when the response is not JSON. The stream=True parameter is for the API endpoint.
642+
content_bytes = self._tapis.files.getContents(
643+
systemId=details.archiveSystemId,
644+
path=full_archive_path,
645+
stream=True, # Good to keep, as it's a hint for the server
646+
)
647+
648+
# Verify that we indeed received bytes
649+
if not isinstance(content_bytes, bytes):
650+
raise FileOperationError(
651+
f"Tapis API returned unexpected type for file content of '{output_filename}': {type(content_bytes)}. Expected bytes."
652+
)
653+
654+
content_str = content_bytes.decode(
655+
"utf-8", errors="replace"
656+
) # Decode to string
657+
658+
if max_lines is not None and max_lines > 0:
659+
lines = content_str.splitlines()
660+
if len(lines) > max_lines:
661+
# Slice to get the last max_lines
662+
content_str = "\n".join(lines[-max_lines:])
663+
print(f"Returning last {max_lines} lines of '{output_filename}'.")
664+
else:
665+
print(
666+
f"File '{output_filename}' has {len(lines)} lines (less than/equal to max_lines={max_lines}). Returning full content."
667+
)
668+
else:
669+
print(f"Returning full content of '{output_filename}'.")
670+
return content_str
671+
672+
except BaseTapyException as e:
673+
if hasattr(e, "response") and e.response and e.response.status_code == 404:
674+
if missing_ok:
675+
print(
676+
f"Output file '{output_filename}' not found in archive (missing_ok=True). Path: {details.archiveSystemId}/{full_archive_path}"
677+
)
678+
return None
679+
else:
680+
raise FileOperationError(
681+
f"Output file '{output_filename}' not found in job archive "
682+
f"at system '{details.archiveSystemId}', path '{full_archive_path}'."
683+
) from e
684+
else:
685+
raise FileOperationError(
686+
f"Tapis error fetching output file '{output_filename}' for job {self.uuid} (Path: {details.archiveSystemId}/{full_archive_path}): {e}"
687+
) from e
688+
except FileOperationError: # Re-raise FileOperationErrors from above
689+
raise
690+
except Exception as e: # Catch other unexpected errors
691+
raise FileOperationError(
692+
f"Unexpected error fetching content of '{output_filename}' for job {self.uuid} (Path: {details.archiveSystemId}/{full_archive_path}): {e}"
693+
) from e
694+
579695
def cancel(self):
580696
print(f"Attempting to cancel job {self.uuid}...")
581697
try:

0 commit comments

Comments
 (0)