|
| 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)}") |
0 commit comments