nepher 0.1.0__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.
- nepher/__init__.py +36 -0
- nepher/api/__init__.py +6 -0
- nepher/api/client.py +384 -0
- nepher/api/endpoints.py +97 -0
- nepher/auth.py +150 -0
- nepher/cli/__init__.py +2 -0
- nepher/cli/commands/__init__.py +6 -0
- nepher/cli/commands/auth.py +37 -0
- nepher/cli/commands/cache.py +85 -0
- nepher/cli/commands/config.py +77 -0
- nepher/cli/commands/download.py +72 -0
- nepher/cli/commands/list.py +75 -0
- nepher/cli/commands/upload.py +69 -0
- nepher/cli/commands/view.py +310 -0
- nepher/cli/main.py +30 -0
- nepher/cli/utils.py +28 -0
- nepher/config.py +202 -0
- nepher/core.py +67 -0
- nepher/env_cfgs/__init__.py +7 -0
- nepher/env_cfgs/base.py +32 -0
- nepher/env_cfgs/manipulation/__init__.py +4 -0
- nepher/env_cfgs/navigation/__init__.py +45 -0
- nepher/env_cfgs/navigation/abstract_nav_cfg.py +159 -0
- nepher/env_cfgs/navigation/preset_nav_cfg.py +590 -0
- nepher/env_cfgs/navigation/usd_nav_cfg.py +644 -0
- nepher/env_cfgs/registry.py +31 -0
- nepher/loader/__init__.py +9 -0
- nepher/loader/base.py +27 -0
- nepher/loader/category_loaders/__init__.py +2 -0
- nepher/loader/preset_loader.py +80 -0
- nepher/loader/registry.py +63 -0
- nepher/loader/usd_loader.py +49 -0
- nepher/storage/__init__.py +8 -0
- nepher/storage/bundle.py +78 -0
- nepher/storage/cache.py +145 -0
- nepher/storage/manifest.py +80 -0
- nepher/utils/__init__.py +12 -0
- nepher/utils/fast_spawn_sampler.py +334 -0
- nepher/utils/free_zone_finder.py +239 -0
- nepher-0.1.0.dist-info/METADATA +235 -0
- nepher-0.1.0.dist-info/RECORD +45 -0
- nepher-0.1.0.dist-info/WHEEL +5 -0
- nepher-0.1.0.dist-info/entry_points.txt +2 -0
- nepher-0.1.0.dist-info/licenses/LICENSE +97 -0
- nepher-0.1.0.dist-info/top_level.txt +1 -0
nepher/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nepher: Universal Isaac Lab Environments Platform
|
|
3
|
+
|
|
4
|
+
A unified, category-agnostic Python package for managing Isaac Lab environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
from nepher.config import get_config, set_config
|
|
10
|
+
from nepher.auth import login, logout, whoami, get_api_key
|
|
11
|
+
from nepher.api.client import get_client, APIClient
|
|
12
|
+
from nepher.core import Environment, Scene
|
|
13
|
+
from nepher.loader import load_env, load_scene
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# Version
|
|
17
|
+
"__version__",
|
|
18
|
+
# Configuration
|
|
19
|
+
"get_config",
|
|
20
|
+
"set_config",
|
|
21
|
+
# Authentication
|
|
22
|
+
"login",
|
|
23
|
+
"logout",
|
|
24
|
+
"whoami",
|
|
25
|
+
"get_api_key",
|
|
26
|
+
# API Client
|
|
27
|
+
"get_client",
|
|
28
|
+
"APIClient",
|
|
29
|
+
# Core types
|
|
30
|
+
"Environment",
|
|
31
|
+
"Scene",
|
|
32
|
+
# Loaders
|
|
33
|
+
"load_env",
|
|
34
|
+
"load_scene",
|
|
35
|
+
]
|
|
36
|
+
|
nepher/api/__init__.py
ADDED
nepher/api/client.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Category-agnostic API client for envhub-backend.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Dict, Any
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from nepher.config import get_config
|
|
10
|
+
from nepher.api.endpoints import APIEndpoints
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(Exception):
|
|
14
|
+
"""API client error."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIClient:
|
|
20
|
+
"""Category-agnostic API client."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None):
|
|
23
|
+
"""
|
|
24
|
+
Initialize API client.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
api_url: API base URL (defaults to config)
|
|
28
|
+
api_key: API key or JWT token (defaults to config)
|
|
29
|
+
"""
|
|
30
|
+
config = get_config()
|
|
31
|
+
self.api_url = (api_url or config.get_api_url()).rstrip("/")
|
|
32
|
+
self.api_key = api_key or config.get_api_key()
|
|
33
|
+
self.session = requests.Session()
|
|
34
|
+
self._jwt_token: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
if self.api_key:
|
|
37
|
+
if self.api_key.startswith("nepher_"):
|
|
38
|
+
self._raw_api_key = self.api_key
|
|
39
|
+
else:
|
|
40
|
+
self._jwt_token = self.api_key
|
|
41
|
+
self._raw_api_key = None
|
|
42
|
+
self.session.headers.update({"Authorization": f"Bearer {self._jwt_token}"})
|
|
43
|
+
else:
|
|
44
|
+
self._raw_api_key = None
|
|
45
|
+
|
|
46
|
+
def _ensure_jwt_token(self):
|
|
47
|
+
"""Exchange API key for JWT token if needed."""
|
|
48
|
+
if self._raw_api_key and not self._jwt_token:
|
|
49
|
+
original_auth = self.session.headers.get("Authorization")
|
|
50
|
+
self.session.headers.pop("Authorization", None)
|
|
51
|
+
try:
|
|
52
|
+
response = self.session.post(
|
|
53
|
+
f"{self.api_url}{APIEndpoints.API_KEY_LOGIN}",
|
|
54
|
+
json={"api_key": self._raw_api_key},
|
|
55
|
+
timeout=30,
|
|
56
|
+
)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
login_data = response.json()
|
|
59
|
+
self._jwt_token = login_data.get("access_token")
|
|
60
|
+
if self._jwt_token:
|
|
61
|
+
self.session.headers.update({"Authorization": f"Bearer {self._jwt_token}"})
|
|
62
|
+
except Exception:
|
|
63
|
+
if original_auth:
|
|
64
|
+
self.session.headers.update({"Authorization": original_auth})
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
def _request(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
endpoint: str,
|
|
71
|
+
params: Optional[Dict[str, Any]] = None,
|
|
72
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
73
|
+
data: Optional[Dict[str, Any]] = None,
|
|
74
|
+
files: Optional[Dict[str, Any]] = None,
|
|
75
|
+
stream: bool = False,
|
|
76
|
+
) -> requests.Response:
|
|
77
|
+
"""
|
|
78
|
+
Make HTTP request.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
method: HTTP method (GET, POST, etc.)
|
|
82
|
+
endpoint: API endpoint path
|
|
83
|
+
params: URL query parameters
|
|
84
|
+
json_data: JSON request body (renamed from 'json' to avoid shadowing builtin)
|
|
85
|
+
data: Form data (used when files are present)
|
|
86
|
+
files: Files to upload (multipart/form-data)
|
|
87
|
+
stream: Whether to stream the response
|
|
88
|
+
|
|
89
|
+
Note: When files are provided, data should be used for form fields instead of json_data.
|
|
90
|
+
"""
|
|
91
|
+
if endpoint != APIEndpoints.API_KEY_LOGIN:
|
|
92
|
+
self._ensure_jwt_token()
|
|
93
|
+
|
|
94
|
+
url = f"{self.api_url}{endpoint}"
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
if files:
|
|
98
|
+
response = self.session.request(
|
|
99
|
+
method=method,
|
|
100
|
+
url=url,
|
|
101
|
+
params=params,
|
|
102
|
+
data=data,
|
|
103
|
+
files=files,
|
|
104
|
+
stream=stream,
|
|
105
|
+
timeout=30,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
response = self.session.request(
|
|
109
|
+
method=method,
|
|
110
|
+
url=url,
|
|
111
|
+
params=params,
|
|
112
|
+
json=json_data,
|
|
113
|
+
data=data,
|
|
114
|
+
stream=stream,
|
|
115
|
+
timeout=30,
|
|
116
|
+
)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
return response
|
|
119
|
+
except requests.exceptions.HTTPError as e:
|
|
120
|
+
error_msg = "API request failed"
|
|
121
|
+
if e.response is not None:
|
|
122
|
+
try:
|
|
123
|
+
error_data = e.response.json()
|
|
124
|
+
error_msg = error_data.get("message", error_data.get("detail", error_msg))
|
|
125
|
+
except (ValueError, KeyError):
|
|
126
|
+
error_msg = e.response.text or error_msg
|
|
127
|
+
raise APIError(f"{error_msg}") from e
|
|
128
|
+
except requests.exceptions.RequestException as e:
|
|
129
|
+
raise APIError(f"Request failed: {str(e)}") from e
|
|
130
|
+
|
|
131
|
+
def health_check(self) -> Dict[str, Any]:
|
|
132
|
+
"""Check API health."""
|
|
133
|
+
response = self._request("GET", APIEndpoints.HEALTH)
|
|
134
|
+
return response.json()
|
|
135
|
+
|
|
136
|
+
def get_info(self) -> Dict[str, Any]:
|
|
137
|
+
"""Get API information."""
|
|
138
|
+
response = self._request("GET", APIEndpoints.INFO)
|
|
139
|
+
return response.json()
|
|
140
|
+
|
|
141
|
+
def list_environments(
|
|
142
|
+
self,
|
|
143
|
+
category: Optional[str] = None,
|
|
144
|
+
type: Optional[str] = None,
|
|
145
|
+
benchmark: Optional[bool] = None,
|
|
146
|
+
search: Optional[str] = None,
|
|
147
|
+
limit: Optional[int] = None,
|
|
148
|
+
offset: Optional[int] = None,
|
|
149
|
+
) -> List[Dict[str, Any]]:
|
|
150
|
+
"""
|
|
151
|
+
List environments.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
category: Filter by category
|
|
155
|
+
type: Filter by type ("usd" or "preset")
|
|
156
|
+
benchmark: Filter by benchmark status
|
|
157
|
+
search: Search query
|
|
158
|
+
limit: Maximum number of results
|
|
159
|
+
offset: Offset for pagination
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
List of environment dictionaries
|
|
163
|
+
"""
|
|
164
|
+
params = {}
|
|
165
|
+
if category:
|
|
166
|
+
params["category"] = category
|
|
167
|
+
if type:
|
|
168
|
+
params["type"] = type
|
|
169
|
+
if benchmark is not None:
|
|
170
|
+
params["benchmark"] = benchmark
|
|
171
|
+
if search:
|
|
172
|
+
params["search"] = search
|
|
173
|
+
if limit:
|
|
174
|
+
params["limit"] = limit
|
|
175
|
+
if offset:
|
|
176
|
+
params["offset"] = offset
|
|
177
|
+
|
|
178
|
+
response = self._request("GET", APIEndpoints.ENVS, params=params)
|
|
179
|
+
result = response.json()
|
|
180
|
+
if isinstance(result, dict) and "environments" in result:
|
|
181
|
+
return result["environments"]
|
|
182
|
+
return result if isinstance(result, list) else []
|
|
183
|
+
|
|
184
|
+
def list_eval_benchmarks(self) -> List[Dict[str, Any]]:
|
|
185
|
+
"""
|
|
186
|
+
List evaluation benchmarks.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of evaluation benchmark dictionaries
|
|
190
|
+
"""
|
|
191
|
+
response = self._request("GET", APIEndpoints.ENVS_EVAL_BENCHMARKS)
|
|
192
|
+
result = response.json()
|
|
193
|
+
if isinstance(result, dict) and "environments" in result:
|
|
194
|
+
return result["environments"]
|
|
195
|
+
return result if isinstance(result, list) else []
|
|
196
|
+
|
|
197
|
+
def get_environment(self, env_id: str) -> Dict[str, Any]:
|
|
198
|
+
"""Get environment details."""
|
|
199
|
+
response = self._request("GET", APIEndpoints.env(env_id))
|
|
200
|
+
return response.json()
|
|
201
|
+
|
|
202
|
+
def download_environment(self, env_id: str, dest_path: Path) -> Path:
|
|
203
|
+
"""
|
|
204
|
+
Download environment bundle.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
env_id: Environment ID
|
|
208
|
+
dest_path: Destination path for the ZIP file
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Path to downloaded file
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
APIError: If download fails
|
|
215
|
+
OSError: If file cannot be written
|
|
216
|
+
"""
|
|
217
|
+
response = self._request("GET", APIEndpoints.env_download(env_id), stream=True)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
with open(dest_path, "wb") as f:
|
|
223
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
224
|
+
if chunk: # Filter out keep-alive chunks
|
|
225
|
+
f.write(chunk)
|
|
226
|
+
except (OSError, IOError) as e:
|
|
227
|
+
raise OSError(f"Cannot write downloaded file to {dest_path}: {e}") from e
|
|
228
|
+
|
|
229
|
+
return dest_path
|
|
230
|
+
|
|
231
|
+
def upload_environment(
|
|
232
|
+
self,
|
|
233
|
+
bundle_path: Path,
|
|
234
|
+
category: str,
|
|
235
|
+
benchmark: bool = False,
|
|
236
|
+
force: bool = False,
|
|
237
|
+
duplicate_policy: str = "reject",
|
|
238
|
+
thumbnail: Optional[Path] = None,
|
|
239
|
+
) -> Dict[str, Any]:
|
|
240
|
+
"""
|
|
241
|
+
Upload environment bundle.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
bundle_path: Path to bundle ZIP file
|
|
245
|
+
category: Environment category
|
|
246
|
+
benchmark: Whether this is a benchmark environment
|
|
247
|
+
force: Force upload even if duplicate exists
|
|
248
|
+
duplicate_policy: Policy for duplicates ("reject", "allow", "update")
|
|
249
|
+
thumbnail: Optional thumbnail image path
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Upload result dictionary
|
|
253
|
+
"""
|
|
254
|
+
bundle_file = open(bundle_path, "rb")
|
|
255
|
+
files = {"bundle": (bundle_path.name, bundle_file, "application/zip")}
|
|
256
|
+
file_handles = [bundle_file]
|
|
257
|
+
|
|
258
|
+
if thumbnail:
|
|
259
|
+
thumbnail_path = Path(thumbnail)
|
|
260
|
+
content_type = "image/jpeg"
|
|
261
|
+
if thumbnail_path.suffix.lower() == ".png":
|
|
262
|
+
content_type = "image/png"
|
|
263
|
+
elif thumbnail_path.suffix.lower() == ".webp":
|
|
264
|
+
content_type = "image/webp"
|
|
265
|
+
elif thumbnail_path.suffix.lower() in [".jpg", ".jpeg"]:
|
|
266
|
+
content_type = "image/jpeg"
|
|
267
|
+
|
|
268
|
+
thumbnail_file = open(thumbnail, "rb")
|
|
269
|
+
files["thumbnail"] = (thumbnail_path.name, thumbnail_file, content_type)
|
|
270
|
+
file_handles.append(thumbnail_file)
|
|
271
|
+
|
|
272
|
+
data = {
|
|
273
|
+
"category": category,
|
|
274
|
+
"benchmark": str(benchmark).lower(),
|
|
275
|
+
"force": str(force).lower(),
|
|
276
|
+
"duplicate_policy": duplicate_policy,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
response = self._request("POST", APIEndpoints.ENVS, files=files, data=data)
|
|
281
|
+
return response.json()
|
|
282
|
+
finally:
|
|
283
|
+
for f in file_handles:
|
|
284
|
+
f.close()
|
|
285
|
+
|
|
286
|
+
def get_user_info(self) -> Dict[str, Any]:
|
|
287
|
+
"""Get current user information."""
|
|
288
|
+
response = self._request("GET", APIEndpoints.USERS_ME)
|
|
289
|
+
return response.json()
|
|
290
|
+
|
|
291
|
+
def api_key_login(self, api_key: str) -> Dict[str, Any]:
|
|
292
|
+
"""
|
|
293
|
+
Exchange API key for JWT tokens.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
api_key: API key to exchange
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dictionary containing access_token, refresh_token, and user info
|
|
300
|
+
"""
|
|
301
|
+
response = self._request(
|
|
302
|
+
"POST", APIEndpoints.API_KEY_LOGIN, json_data={"api_key": api_key}
|
|
303
|
+
)
|
|
304
|
+
return response.json()
|
|
305
|
+
|
|
306
|
+
def create_api_key(
|
|
307
|
+
self,
|
|
308
|
+
name: Optional[str] = None,
|
|
309
|
+
expires_at: Optional[datetime] = None,
|
|
310
|
+
) -> Dict[str, Any]:
|
|
311
|
+
"""
|
|
312
|
+
Create a new API key.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
name: Optional name for the API key
|
|
316
|
+
expires_at: Optional expiration date
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Dictionary containing API key (shown only once) and metadata
|
|
320
|
+
"""
|
|
321
|
+
data = {}
|
|
322
|
+
if name:
|
|
323
|
+
data["name"] = name
|
|
324
|
+
if expires_at:
|
|
325
|
+
data["expires_at"] = expires_at.isoformat()
|
|
326
|
+
response = self._request("POST", APIEndpoints.API_KEYS, json_data=data)
|
|
327
|
+
return response.json()
|
|
328
|
+
|
|
329
|
+
def list_api_keys(self) -> List[Dict[str, Any]]:
|
|
330
|
+
"""
|
|
331
|
+
List user's API keys.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of API key dictionaries (without key values)
|
|
335
|
+
"""
|
|
336
|
+
response = self._request("GET", APIEndpoints.API_KEYS)
|
|
337
|
+
return response.json()
|
|
338
|
+
|
|
339
|
+
def get_api_key(self, api_key_id: str) -> Dict[str, Any]:
|
|
340
|
+
"""
|
|
341
|
+
Get API key details.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
api_key_id: API key ID
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
API key dictionary (without key value)
|
|
348
|
+
"""
|
|
349
|
+
response = self._request("GET", APIEndpoints.api_key(api_key_id))
|
|
350
|
+
return response.json()
|
|
351
|
+
|
|
352
|
+
def delete_api_key(self, api_key_id: str) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Delete an API key.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
api_key_id: API key ID
|
|
358
|
+
"""
|
|
359
|
+
self._request("DELETE", APIEndpoints.api_key(api_key_id))
|
|
360
|
+
|
|
361
|
+
def regenerate_api_key(self, api_key_id: str) -> Dict[str, Any]:
|
|
362
|
+
"""
|
|
363
|
+
Regenerate an API key (creates new key, deactivates old one).
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
api_key_id: API key ID to regenerate
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Dictionary containing new API key (shown only once) and metadata
|
|
370
|
+
"""
|
|
371
|
+
response = self._request("POST", APIEndpoints.api_key_regenerate(api_key_id))
|
|
372
|
+
return response.json()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
_client_instance: Optional[APIClient] = None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def get_client(api_url: Optional[str] = None, api_key: Optional[str] = None) -> APIClient:
|
|
379
|
+
"""Get global API client instance."""
|
|
380
|
+
global _client_instance
|
|
381
|
+
if _client_instance is None:
|
|
382
|
+
_client_instance = APIClient(api_url=api_url, api_key=api_key)
|
|
383
|
+
return _client_instance
|
|
384
|
+
|
nepher/api/endpoints.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API endpoint definitions for envhub-backend.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIEndpoints:
|
|
9
|
+
"""API endpoint paths."""
|
|
10
|
+
|
|
11
|
+
# Health & Info
|
|
12
|
+
HEALTH = "/api/v1/health"
|
|
13
|
+
INFO = "/" # Root endpoint for API info
|
|
14
|
+
|
|
15
|
+
# Environment Management
|
|
16
|
+
ENVS = "/api/v1/envs/"
|
|
17
|
+
ENVS_PUBLIC = "/api/v1/envs/public/"
|
|
18
|
+
ENVS_BENCHMARK = "/api/v1/envs/benchmark/"
|
|
19
|
+
ENVS_EVAL_BENCHMARKS = "/api/v1/envs/eval-benchmarks/"
|
|
20
|
+
ENVS_PENDING = "/api/v1/envs/pending/"
|
|
21
|
+
ENVS_TRASH = "/api/v1/envs/trash/"
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def env(env_id: str) -> str:
|
|
25
|
+
"""Get environment endpoint."""
|
|
26
|
+
return f"/api/v1/envs/{env_id}"
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def env_download(env_id: str) -> str:
|
|
30
|
+
"""Download environment endpoint."""
|
|
31
|
+
return f"/api/v1/envs/{env_id}/download"
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def env_thumbnail(env_id: str) -> str:
|
|
35
|
+
"""Get environment thumbnail endpoint."""
|
|
36
|
+
return f"/api/v1/envs/{env_id}/thumbnail"
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def env_approve(env_id: str) -> str:
|
|
40
|
+
"""Approve environment endpoint."""
|
|
41
|
+
return f"/api/v1/envs/{env_id}/approve"
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def env_reject(env_id: str) -> str:
|
|
45
|
+
"""Reject environment endpoint."""
|
|
46
|
+
return f"/api/v1/envs/{env_id}/reject"
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def env_activate_evaluation(env_id: str) -> str:
|
|
50
|
+
"""Activate evaluation endpoint."""
|
|
51
|
+
return f"/api/v1/envs/{env_id}/activate-evaluation"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def env_deactivate_evaluation(env_id: str) -> str:
|
|
55
|
+
"""Deactivate evaluation endpoint."""
|
|
56
|
+
return f"/api/v1/envs/{env_id}/deactivate-evaluation"
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def env_toggle_benchmark(env_id: str) -> str:
|
|
60
|
+
"""Toggle benchmark endpoint."""
|
|
61
|
+
return f"/api/v1/envs/{env_id}/toggle-benchmark"
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def env_restore(env_id: str) -> str:
|
|
65
|
+
"""Restore environment endpoint."""
|
|
66
|
+
return f"/api/v1/envs/{env_id}/restore"
|
|
67
|
+
|
|
68
|
+
# Authentication
|
|
69
|
+
API_KEY_LOGIN = "/api/v1/auth/api-key/login"
|
|
70
|
+
|
|
71
|
+
# API Key Management
|
|
72
|
+
API_KEYS = "/api/v1/api-keys/"
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def api_key(api_key_id: str) -> str:
|
|
76
|
+
"""Get/delete API key endpoint."""
|
|
77
|
+
return f"/api/v1/api-keys/{api_key_id}"
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def api_key_regenerate(api_key_id: str) -> str:
|
|
81
|
+
"""Regenerate API key endpoint."""
|
|
82
|
+
return f"/api/v1/api-keys/{api_key_id}/regenerate"
|
|
83
|
+
|
|
84
|
+
# User Management
|
|
85
|
+
USERS_ME = "/api/v1/users/me"
|
|
86
|
+
USERS = "/api/v1/users/"
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def user_envhub_role(user_id: str) -> str:
|
|
90
|
+
"""Get/update user envhub role endpoint."""
|
|
91
|
+
return f"/api/v1/users/{user_id}/envhub-role"
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def user_status(user_id: str) -> str:
|
|
95
|
+
"""Update user status endpoint."""
|
|
96
|
+
return f"/api/v1/users/{user_id}/status"
|
|
97
|
+
|
nepher/auth.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication management for Nepher.
|
|
3
|
+
|
|
4
|
+
Handles secure API key storage and user authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from nepher.config import get_config, set_config
|
|
11
|
+
from nepher.api.client import APIClient, APIError
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import keyring
|
|
15
|
+
HAS_KEYRING = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HAS_KEYRING = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_keyring_service() -> str:
|
|
21
|
+
"""Get keyring service name."""
|
|
22
|
+
return "nepher"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_keyring_username() -> str:
|
|
26
|
+
"""Get keyring username."""
|
|
27
|
+
return "api_key"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_encrypted_file_path() -> Path:
|
|
31
|
+
"""Get path to encrypted API key file."""
|
|
32
|
+
config_dir = Path.home() / ".nepher"
|
|
33
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return config_dir / "api_key.enc"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _store_api_key_secure(api_key: str):
|
|
38
|
+
"""Store API key securely (keyring or encrypted file)."""
|
|
39
|
+
if HAS_KEYRING:
|
|
40
|
+
try:
|
|
41
|
+
keyring.set_password(_get_keyring_service(), _get_keyring_username(), api_key)
|
|
42
|
+
return
|
|
43
|
+
except Exception:
|
|
44
|
+
pass # Fall back to file storage
|
|
45
|
+
|
|
46
|
+
import base64
|
|
47
|
+
|
|
48
|
+
encrypted = base64.b64encode(api_key.encode()).decode()
|
|
49
|
+
_get_encrypted_file_path().write_text(encrypted)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_api_key_secure() -> Optional[str]:
|
|
53
|
+
"""Retrieve API key from secure storage."""
|
|
54
|
+
if HAS_KEYRING:
|
|
55
|
+
try:
|
|
56
|
+
key = keyring.get_password(_get_keyring_service(), _get_keyring_username())
|
|
57
|
+
if key:
|
|
58
|
+
return key
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
key_file = _get_encrypted_file_path()
|
|
63
|
+
if key_file.exists():
|
|
64
|
+
try:
|
|
65
|
+
import base64
|
|
66
|
+
|
|
67
|
+
encrypted = key_file.read_text()
|
|
68
|
+
return base64.b64decode(encrypted.encode()).decode()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _clear_api_key_secure():
|
|
76
|
+
"""Clear API key from secure storage."""
|
|
77
|
+
if HAS_KEYRING:
|
|
78
|
+
try:
|
|
79
|
+
keyring.delete_password(_get_keyring_service(), _get_keyring_username())
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
key_file = _get_encrypted_file_path()
|
|
84
|
+
if key_file.exists():
|
|
85
|
+
key_file.unlink()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def login(api_key: str) -> bool:
|
|
89
|
+
"""
|
|
90
|
+
Login with API key.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
api_key: API key to store
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if login successful, False otherwise
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
client = APIClient(api_key=None)
|
|
100
|
+
response = client.api_key_login(api_key)
|
|
101
|
+
if not response or "access_token" not in response:
|
|
102
|
+
return False
|
|
103
|
+
except APIError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Store API key securely
|
|
107
|
+
_store_api_key_secure(api_key)
|
|
108
|
+
set_config("api_key", api_key, save=True)
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def logout():
|
|
114
|
+
"""Logout and clear stored credentials."""
|
|
115
|
+
_clear_api_key_secure()
|
|
116
|
+
set_config("api_key", None, save=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def whoami() -> Optional[dict]:
|
|
120
|
+
"""
|
|
121
|
+
Get current user information.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
User info dictionary or None if not authenticated
|
|
125
|
+
"""
|
|
126
|
+
api_key = get_api_key()
|
|
127
|
+
if not api_key:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
client = APIClient(api_key=api_key)
|
|
132
|
+
return client.get_user_info()
|
|
133
|
+
except APIError:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_api_key() -> Optional[str]:
|
|
138
|
+
"""
|
|
139
|
+
Get stored API key.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
API key or None if not set
|
|
143
|
+
"""
|
|
144
|
+
key = _get_api_key_secure()
|
|
145
|
+
if key:
|
|
146
|
+
return key
|
|
147
|
+
|
|
148
|
+
config = get_config()
|
|
149
|
+
return config.get_api_key() if config else None
|
|
150
|
+
|
nepher/cli/__init__.py
ADDED