Skip to content

Commit 7044f26

Browse files
committed
WIP: Restructure with components and plugins
1 parent 4fd4ddc commit 7044f26

File tree

7 files changed

+630
-4
lines changed

7 files changed

+630
-4
lines changed

Diff for: dapi/__init__.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
```
3737
3838
"""
39-
from . import apps
40-
from . import auth
41-
from . import db
42-
from . import jobs
39+
# from . import apps
40+
# from . import auth
41+
# from . import db
42+
# from . import jobs
43+
44+
from .core import DesignSafeAPI
45+
46+
__all__ = ["DesignSafeAPI", "apps", "auth", "db", "jobs", "core"]

Diff for: dapi/components/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# components/__init__.py

Diff for: dapi/components/files/__init__.py

+253
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# designsafe/components/files/__init__.py
2+
from pathlib import Path
3+
from typing import Optional, List, Union, Dict
4+
from enum import Enum
5+
from dataclasses import dataclass
6+
import os
7+
8+
from ...core import BaseComponent
9+
10+
11+
class StorageSystem(Enum):
12+
"""Enumeration of DesignSafe storage systems."""
13+
14+
MY_DATA = "designsafe.storage.default"
15+
COMMUNITY_DATA = "designsafe.storage.community"
16+
17+
@property
18+
def base_path(self) -> str:
19+
"""Get the base Jupyter path for this storage system."""
20+
return {
21+
StorageSystem.MY_DATA: "jupyter/MyData",
22+
StorageSystem.COMMUNITY_DATA: "jupyter/CommunityData",
23+
}[self]
24+
25+
26+
@dataclass
27+
class FileInfo:
28+
"""Information about a file or directory in DesignSafe."""
29+
30+
name: str
31+
path: str
32+
type: str # 'file' or 'dir'
33+
size: Optional[int]
34+
last_modified: str
35+
uri: str
36+
permissions: Dict[str, bool]
37+
38+
39+
class FilesComponent(BaseComponent):
40+
"""Component for managing files and directories in DesignSafe."""
41+
42+
def _get_project_uuid(self, project_id: str) -> str:
43+
"""Get the UUID for a project given its ID.
44+
45+
Args:
46+
project_id: The project ID
47+
48+
Returns:
49+
The project UUID
50+
51+
Raises:
52+
ValueError: If project not found
53+
"""
54+
try:
55+
resp = self.tapis.get(
56+
f"https://designsafe-ci.org/api/projects/v2/{project_id}"
57+
)
58+
project_data = resp.json()
59+
return project_data["baseProject"]["uuid"]
60+
except Exception as e:
61+
raise ValueError(f"Error getting project UUID for {project_id}: {str(e)}")
62+
63+
def get_uri(self, path: str) -> str:
64+
"""Convert a local or Jupyter path to a Tapis URI.
65+
66+
Args:
67+
path: Local filesystem or Jupyter path
68+
69+
Returns:
70+
Tapis URI for the path
71+
72+
Examples:
73+
>>> ds.files.get_uri("jupyter/MyData/test.txt")
74+
'tapis://designsafe.storage.default/username/test.txt'
75+
76+
>>> ds.files.get_uri("jupyter/CommunityData/test.txt")
77+
'tapis://designsafe.storage.community/test.txt'
78+
79+
>>> ds.files.get_uri("jupyter/MyProjects/PRJ-1234/test.txt")
80+
'tapis://project-uuid/test.txt'
81+
"""
82+
path = str(path) # Convert Path objects to string
83+
84+
# Handle MyData paths
85+
if "MyData" in path or "mydata" in path:
86+
# Extract the relative path after MyData
87+
rel_path = path.split("MyData/")[-1]
88+
return f"tapis://{StorageSystem.MY_DATA.value}/{self.tapis.username}/{rel_path}"
89+
90+
# Handle CommunityData paths
91+
if "CommunityData" in path:
92+
rel_path = path.split("CommunityData/")[-1]
93+
return f"tapis://{StorageSystem.COMMUNITY_DATA.value}/{rel_path}"
94+
95+
# Handle Project paths
96+
if "MyProjects" in path or "projects" in path:
97+
# Extract project ID and relative path
98+
parts = path.split("/")
99+
for i, part in enumerate(parts):
100+
if part in ("MyProjects", "projects"):
101+
project_id = parts[i + 1]
102+
rel_path = "/".join(parts[i + 2 :])
103+
break
104+
else:
105+
raise ValueError("Could not parse project path")
106+
107+
project_uuid = self._get_project_uuid(project_id)
108+
return f"tapis://project-{project_uuid}/{rel_path}"
109+
110+
raise ValueError(f"Could not determine storage system for path: {path}")
111+
112+
def list(self, path: str, recursive: bool = False) -> List[FileInfo]:
113+
"""List contents of a directory.
114+
115+
Args:
116+
path: Path to list
117+
recursive: Whether to list contents recursively
118+
119+
Returns:
120+
List of FileInfo objects
121+
122+
Raises:
123+
Exception: If listing fails
124+
"""
125+
uri = self.get_uri(path)
126+
127+
try:
128+
system_id, path = uri.replace("tapis://", "").split("/", 1)
129+
130+
listing = self.tapis.files.listFiles(
131+
systemId=system_id, path=path, recursive=recursive
132+
)
133+
134+
return [
135+
FileInfo(
136+
name=item.name,
137+
path=item.path,
138+
type="dir" if item.type == "dir" else "file",
139+
size=item.size,
140+
last_modified=item.lastModified,
141+
uri=f"tapis://{system_id}/{item.path}",
142+
permissions={
143+
"read": item.permissions.read,
144+
"write": item.permissions.write,
145+
"execute": item.permissions.execute,
146+
},
147+
)
148+
for item in listing
149+
]
150+
except Exception as e:
151+
raise Exception(f"Error listing {path}: {str(e)}")
152+
153+
def upload(
154+
self, local_path: Union[str, Path], remote_path: str, progress: bool = True
155+
) -> FileInfo:
156+
"""Upload a file or directory to DesignSafe.
157+
158+
Args:
159+
local_path: Path to local file/directory to upload
160+
remote_path: Destination path on DesignSafe
161+
progress: Whether to show progress bar
162+
163+
Returns:
164+
FileInfo object for the uploaded file
165+
166+
Raises:
167+
FileNotFoundError: If local path doesn't exist
168+
Exception: If upload fails
169+
"""
170+
local_path = Path(local_path)
171+
if not local_path.exists():
172+
raise FileNotFoundError(f"Local path not found: {local_path}")
173+
174+
uri = self.get_uri(remote_path)
175+
system_id, path = uri.replace("tapis://", "").split("/", 1)
176+
177+
try:
178+
result = self.tapis.files.upload(
179+
systemId=system_id,
180+
sourcePath=str(local_path),
181+
targetPath=path,
182+
progress=progress,
183+
)
184+
185+
# Return info about the uploaded file
186+
return FileInfo(
187+
name=local_path.name,
188+
path=path,
189+
type="dir" if local_path.is_dir() else "file",
190+
size=local_path.stat().st_size if local_path.is_file() else None,
191+
last_modified=result.lastModified,
192+
uri=uri,
193+
permissions={"read": True, "write": True, "execute": False},
194+
)
195+
except Exception as e:
196+
raise Exception(f"Error uploading {local_path} to {remote_path}: {str(e)}")
197+
198+
def download(
199+
self,
200+
remote_path: str,
201+
local_path: Optional[Union[str, Path]] = None,
202+
progress: bool = True,
203+
) -> Path:
204+
"""Download a file or directory from DesignSafe.
205+
206+
Args:
207+
remote_path: Path on DesignSafe to download
208+
local_path: Local destination path (default: current directory)
209+
progress: Whether to show progress bar
210+
211+
Returns:
212+
Path to downloaded file/directory
213+
214+
Raises:
215+
Exception: If download fails
216+
"""
217+
uri = self.get_uri(remote_path)
218+
system_id, path = uri.replace("tapis://", "").split("/", 1)
219+
220+
# Default to current directory with remote filename
221+
if local_path is None:
222+
local_path = Path.cwd() / Path(path).name
223+
local_path = Path(local_path)
224+
225+
try:
226+
self.tapis.files.download(
227+
systemId=system_id,
228+
path=path,
229+
targetPath=str(local_path),
230+
progress=progress,
231+
)
232+
return local_path
233+
except Exception as e:
234+
raise Exception(
235+
f"Error downloading {remote_path} to {local_path}: {str(e)}"
236+
)
237+
238+
def delete(self, path: str) -> None:
239+
"""Delete a file or directory.
240+
241+
Args:
242+
path: Path to delete
243+
244+
Raises:
245+
Exception: If deletion fails
246+
"""
247+
uri = self.get_uri(path)
248+
system_id, path = uri.replace("tapis://", "").split("/", 1)
249+
250+
try:
251+
self.tapis.files.delete(systemId=system_id, path=path)
252+
except Exception as e:
253+
raise Exception(f"Error deleting {path}: {str(e)}")

Diff for: dapi/components/jobs/__init__.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from typing import Dict, Type, Any, Optional
2+
from .base_job_handler import BaseJobHandler
3+
from ...core import BaseComponent # Importing BaseComponent from core
4+
from datetime import datetime
5+
from tqdm import tqdm
6+
import time
7+
8+
9+
class JobsComponent(BaseComponent):
10+
"""Jobs component for managing Tapis jobs."""
11+
12+
def __init__(self, api):
13+
super().__init__(api)
14+
self.handlers: Dict[str, Type[BaseJobHandler]] = {}
15+
self._register_default_handlers()
16+
17+
def _register_default_handlers(self) -> None:
18+
"""Register default job handlers."""
19+
from .opensees_job_handler import OpenSeesJobHandler # Import default handlers
20+
21+
self.register_handler("opensees", OpenSeesJobHandler)
22+
23+
def register_handler(
24+
self, app_name: str, handler_class: Type[BaseJobHandler]
25+
) -> None:
26+
"""Register a handler for a specific app."""
27+
self.handlers[app_name] = handler_class
28+
29+
# Dynamically add a method for the app
30+
def app_method(
31+
input_file: str,
32+
input_uri: Optional[str] = None,
33+
job_name: Optional[str] = None,
34+
max_minutes: Optional[int] = None,
35+
node_count: Optional[int] = None,
36+
cores_per_node: Optional[int] = None,
37+
queue: Optional[str] = None,
38+
allocation: Optional[str] = None,
39+
) -> Any:
40+
handler = self.handlers[app_name](app_name)
41+
job_info = handler.generate_job_info(
42+
self.tapis,
43+
input_uri or "tapis://example/input/",
44+
input_file,
45+
job_name,
46+
max_minutes,
47+
node_count,
48+
cores_per_node,
49+
queue,
50+
allocation,
51+
)
52+
return job_info
53+
54+
setattr(self, app_name, app_method)
55+
56+
def submit_job(self, job_info: Dict[str, Any]) -> Any:
57+
"""Submit a job to Tapis."""
58+
response = self.tapis.jobs.submitJob(**job_info)
59+
return response
60+
61+
def monitor_job(self, job_uuid: str, interval: int = 15) -> str:
62+
"""Monitor the status of a job."""
63+
status = self.tapis.jobs.getJobStatus(jobUuid=job_uuid).status
64+
max_minutes = self.tapis.jobs.getJob(jobUuid=job_uuid).maxMinutes
65+
previous_status = None
66+
67+
with tqdm(desc="Monitoring Job", dynamic_ncols=True) as pbar:
68+
for _ in range(int(max_minutes * 60 / interval)):
69+
time.sleep(interval)
70+
status = self.tapis.jobs.getJobStatus(jobUuid=job_uuid).status
71+
if status != previous_status:
72+
tqdm.write(f"Status changed: {status}")
73+
previous_status = status
74+
75+
if status in ["FINISHED", "FAILED", "STOPPED"]:
76+
break
77+
pbar.update(1)
78+
79+
return status
80+
81+
def get_job_history(self, job_uuid: str) -> Dict[str, Any]:
82+
"""Retrieve job history and compute timings."""
83+
history = self.tapis.jobs.getJobHistory(jobUuid=job_uuid)
84+
timing_summary = {}
85+
86+
def parse_timestamps(event_list, event_name):
87+
timestamps = [
88+
datetime.strptime(event.created, "%Y-%m-%dT%H:%M:%S.%fZ")
89+
for event in event_list
90+
if event.eventDetail == event_name
91+
]
92+
return timestamps
93+
94+
queued_times = parse_timestamps(history, "QUEUED")
95+
running_times = parse_timestamps(history, "RUNNING")
96+
97+
if queued_times and running_times:
98+
timing_summary["QUEUED"] = (
99+
running_times[0] - queued_times[0]
100+
).total_seconds()
101+
102+
if running_times and len(running_times) > 1:
103+
timing_summary["RUNNING"] = (
104+
running_times[-1] - running_times[0]
105+
).total_seconds()
106+
107+
return timing_summary

0 commit comments

Comments
 (0)