xenfra-sdk 0.2.5__py3-none-any.whl → 0.2.6__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.
Files changed (42) hide show
  1. xenfra_sdk/__init__.py +46 -2
  2. xenfra_sdk/blueprints/base.py +150 -0
  3. xenfra_sdk/blueprints/factory.py +99 -0
  4. xenfra_sdk/blueprints/node.py +219 -0
  5. xenfra_sdk/blueprints/python.py +57 -0
  6. xenfra_sdk/blueprints/railpack.py +99 -0
  7. xenfra_sdk/blueprints/schema.py +70 -0
  8. xenfra_sdk/cli/main.py +175 -49
  9. xenfra_sdk/client.py +6 -2
  10. xenfra_sdk/constants.py +26 -0
  11. xenfra_sdk/db/session.py +8 -3
  12. xenfra_sdk/detection.py +262 -191
  13. xenfra_sdk/dockerizer.py +76 -120
  14. xenfra_sdk/engine.py +758 -172
  15. xenfra_sdk/events.py +254 -0
  16. xenfra_sdk/exceptions.py +9 -0
  17. xenfra_sdk/governance.py +150 -0
  18. xenfra_sdk/manifest.py +93 -138
  19. xenfra_sdk/mcp_client.py +7 -5
  20. xenfra_sdk/{models.py → models/__init__.py} +17 -1
  21. xenfra_sdk/models/context.py +61 -0
  22. xenfra_sdk/orchestrator.py +223 -99
  23. xenfra_sdk/privacy.py +11 -0
  24. xenfra_sdk/protocol.py +38 -0
  25. xenfra_sdk/railpack_adapter.py +357 -0
  26. xenfra_sdk/railpack_detector.py +587 -0
  27. xenfra_sdk/railpack_manager.py +312 -0
  28. xenfra_sdk/recipes.py +152 -19
  29. xenfra_sdk/resources/activity.py +45 -0
  30. xenfra_sdk/resources/build.py +157 -0
  31. xenfra_sdk/resources/deployments.py +22 -2
  32. xenfra_sdk/resources/intelligence.py +25 -0
  33. xenfra_sdk-0.2.6.dist-info/METADATA +118 -0
  34. xenfra_sdk-0.2.6.dist-info/RECORD +49 -0
  35. {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.6.dist-info}/WHEEL +1 -1
  36. xenfra_sdk/templates/Caddyfile.j2 +0 -14
  37. xenfra_sdk/templates/Dockerfile.j2 +0 -41
  38. xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
  39. xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
  40. xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
  41. xenfra_sdk-0.2.5.dist-info/METADATA +0 -116
  42. xenfra_sdk-0.2.5.dist-info/RECORD +0 -38
@@ -0,0 +1,312 @@
1
+ """
2
+ Railpack Manager - Auto-downloads and manages Railpack CLI binary.
3
+
4
+ Railpack is Railway's open-source buildpack that auto-detects languages
5
+ and generates optimized container configurations.
6
+ """
7
+
8
+ import os
9
+ import platform
10
+ import shutil
11
+ import stat
12
+ import subprocess
13
+ import tarfile
14
+ import urllib.request
15
+ import zipfile
16
+ from pathlib import Path
17
+ from typing import Optional
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class RailpackManager:
24
+ """
25
+ Manages Railpack CLI binary lifecycle.
26
+
27
+ Auto-downloads Railpack from GitHub releases if not present.
28
+ Stores binary in ~/.xenfra/bin/ for persistence across sessions.
29
+ """
30
+
31
+ RAILPACK_VERSION = "0.17.1"
32
+
33
+ # GitHub release URLs for different platforms
34
+ DOWNLOAD_URLS = {
35
+ ("Linux", "x86_64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-unknown-linux-musl.tar.gz",
36
+ ("Linux", "aarch64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-arm64-unknown-linux-musl.tar.gz",
37
+ ("Darwin", "x86_64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-apple-darwin.tar.gz",
38
+ ("Darwin", "arm64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-arm64-apple-darwin.tar.gz",
39
+ ("Windows", "AMD64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-pc-windows-msvc.zip",
40
+ }
41
+
42
+ def __init__(self, version: Optional[str] = None):
43
+ """
44
+ Initialize Railpack manager.
45
+
46
+ Args:
47
+ version: Specific Railpack version to use. Defaults to RAILPACK_VERSION.
48
+ """
49
+ self.version = version or self.RAILPACK_VERSION
50
+ self.bin_dir = Path.home() / ".xenfra" / "bin"
51
+ self.bin_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ # Determine binary name based on platform
54
+ system = platform.system()
55
+ self.binary_name = "railpack.exe" if system == "Windows" else "railpack"
56
+ self.binary_path = self.bin_dir / self.binary_name
57
+
58
+ # Cache availability check
59
+ self._is_available: Optional[bool] = None
60
+
61
+ def is_installed(self) -> bool:
62
+ """Check if Railpack binary exists and is executable."""
63
+ if self._is_available is not None:
64
+ return self._is_available
65
+
66
+ if not self.binary_path.exists():
67
+ self._is_available = False
68
+ return False
69
+
70
+ # Verify it's actually runnable
71
+ try:
72
+ result = subprocess.run(
73
+ [str(self.binary_path), "--version"],
74
+ capture_output=True,
75
+ timeout=5
76
+ )
77
+ self._is_available = result.returncode == 0
78
+ return self._is_available
79
+ except (subprocess.TimeoutExpired, OSError):
80
+ self._is_available = False
81
+ return False
82
+
83
+ def ensure_installed(self) -> str:
84
+ """
85
+ Ensure Railpack is installed. Download if needed.
86
+
87
+ Returns:
88
+ Absolute path to railpack binary.
89
+
90
+ Raises:
91
+ RuntimeError: If installation fails or platform unsupported.
92
+ """
93
+ if self.is_installed():
94
+ return str(self.binary_path)
95
+
96
+ return self._download_and_install()
97
+
98
+ def _get_download_url(self) -> str:
99
+ """Get download URL for current platform."""
100
+ system = platform.system()
101
+ machine = platform.machine()
102
+
103
+ # Normalize machine names
104
+ if machine in ("amd64", "x86_64"):
105
+ machine = "x86_64"
106
+ elif machine in ("arm64", "aarch64"):
107
+ machine = "arm64" if system == "Darwin" else "aarch64"
108
+
109
+ key = (system, machine)
110
+
111
+ if key not in self.DOWNLOAD_URLS:
112
+ # Try x86_64 as fallback for Linux
113
+ if system == "Linux":
114
+ key = ("Linux", "x86_64")
115
+ else:
116
+ raise RuntimeError(
117
+ f"Unsupported platform: {system} {machine}. "
118
+ f"Railpack supports: Linux (x86_64, arm64), macOS (x86_64, arm64), Windows (x86_64)"
119
+ )
120
+
121
+ return self.DOWNLOAD_URLS[key].format(version=self.version)
122
+
123
+ def _download_and_install(self) -> str:
124
+ """Download and install Railpack binary."""
125
+ import tempfile
126
+
127
+ url = self._get_download_url()
128
+ system = platform.system()
129
+
130
+ print(f"📦 Downloading Railpack {self.version}...")
131
+
132
+ with tempfile.TemporaryDirectory() as tmpdir:
133
+ tmp_path = Path(tmpdir)
134
+
135
+ # Determine archive type
136
+ is_zip = url.endswith(".zip")
137
+ archive_name = "railpack.zip" if is_zip else "railpack.tar.gz"
138
+ download_path = tmp_path / archive_name
139
+
140
+ # Download with progress
141
+ try:
142
+ self._download_with_progress(url, download_path)
143
+ except urllib.error.HTTPError as e:
144
+ raise RuntimeError(f"Failed to download Railpack: {e}")
145
+
146
+ print(f"📂 Extracting {archive_name}...")
147
+
148
+ # Extract
149
+ if is_zip:
150
+ self._extract_zip(download_path, tmp_path)
151
+ else:
152
+ self._extract_tar(download_path, tmp_path)
153
+
154
+ # Find extracted binary recursively
155
+ extracted_binary = None
156
+ for root, dirs, files in os.walk(tmp_path):
157
+ if self.binary_name in files:
158
+ extracted_binary = Path(root) / self.binary_name
159
+ break
160
+
161
+ if not extracted_binary:
162
+ raise RuntimeError(f"Railpack binary '{self.binary_name}' not found in extracted archive")
163
+
164
+ # Move to bin directory (Robust cross-device move)
165
+ try:
166
+ shutil.copy2(str(extracted_binary), str(self.binary_path))
167
+ os.remove(str(extracted_binary))
168
+ except Exception as e:
169
+ # If copy/delete fails, try move as fallback but it might fail on cross-device
170
+ logger.warning(f"Copy/remove failed: {e}. Trying shutil.move...")
171
+ shutil.move(str(extracted_binary), str(self.binary_path))
172
+
173
+ # Make executable (Unix)
174
+ if system != "Windows":
175
+ self.binary_path.chmod(
176
+ self.binary_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
177
+ )
178
+
179
+ # Reset availability cache so verification actually runs the new binary
180
+ self._is_available = None
181
+
182
+ # Verify installation
183
+ if not self.is_installed():
184
+ raise RuntimeError("Railpack installation verification failed")
185
+
186
+ print(f"✅ Railpack {self.version} installed at {self.binary_path}")
187
+ return str(self.binary_path)
188
+
189
+ def _download_with_progress(self, url: str, dest: Path):
190
+ """Download file with simple progress indication."""
191
+ def report_progress(block_num, block_size, total_size):
192
+ downloaded = block_num * block_size
193
+ percent = min(downloaded * 100 // total_size, 100) if total_size > 0 else 0
194
+ if block_num % 10 == 0: # Update every 10 blocks
195
+ print(f" Downloading... {percent}%", end="\r")
196
+
197
+ urllib.request.urlretrieve(url, dest, reporthook=report_progress)
198
+ print() # Newline after progress
199
+
200
+ def _extract_zip(self, zip_path: Path, dest_dir: Path):
201
+ """Extract zip archive."""
202
+ with zipfile.ZipFile(zip_path, 'r') as zf:
203
+ zf.extractall(dest_dir)
204
+
205
+ def _extract_tar(self, tar_path: Path, dest_dir: Path):
206
+ """Extract tar.gz archive."""
207
+ with tarfile.open(tar_path, 'r:gz') as tf:
208
+ tf.extractall(dest_dir)
209
+
210
+ def run(self, *args, cwd: Optional[str] = None,
211
+ capture_output: bool = True, text: bool = True,
212
+ timeout: int = 60, **kwargs) -> subprocess.CompletedProcess:
213
+ """
214
+ Run Railpack command.
215
+
216
+ Args:
217
+ *args: Command arguments (e.g., "prepare", ".", "--plan-out", "plan.json")
218
+ cwd: Working directory
219
+ capture_output: Capture stdout/stderr
220
+ text: Return text instead of bytes
221
+ timeout: Command timeout in seconds
222
+ **kwargs: Additional subprocess arguments
223
+
224
+ Returns:
225
+ CompletedProcess with returncode, stdout, stderr
226
+
227
+ Raises:
228
+ subprocess.CalledProcessError: If command fails and check=True
229
+ subprocess.TimeoutExpired: If timeout reached
230
+ """
231
+ binary = self.ensure_installed()
232
+ cmd = [binary] + list(args)
233
+
234
+ return subprocess.run(
235
+ cmd,
236
+ cwd=cwd,
237
+ capture_output=capture_output,
238
+ text=text,
239
+ timeout=timeout,
240
+ **kwargs
241
+ )
242
+
243
+ def prepare(self, source_path: str, plan_out: Optional[str] = None,
244
+ info_out: Optional[str] = None, env: Optional[dict] = None) -> dict:
245
+ """
246
+ Run 'railpack prepare' to generate build plan.
247
+
248
+ Args:
249
+ source_path: Path to source code directory
250
+ plan_out: Output path for plan JSON (default: source_path/.railpack-plan.json)
251
+ info_out: Output path for build info JSON
252
+ env: Environment variables for Railpack
253
+
254
+ Returns:
255
+ Parsed plan JSON as dict
256
+
257
+ Raises:
258
+ RuntimeError: If Railpack fails
259
+ """
260
+ import json
261
+
262
+ if plan_out is None:
263
+ plan_out = os.path.join(source_path, ".railpack-plan.json")
264
+
265
+ args = ["prepare", source_path, "--plan-out", plan_out]
266
+
267
+ if info_out:
268
+ args.extend(["--info-out", info_out])
269
+
270
+ # Add env vars
271
+ env_vars = os.environ.copy()
272
+ if env:
273
+ env_vars.update(env)
274
+
275
+ result = self.run(*args, env=env_vars, check=True)
276
+
277
+ # Read and return plan
278
+ with open(plan_out) as f:
279
+ return json.load(f)
280
+
281
+ def get_version(self) -> str:
282
+ """Get installed Railpack version."""
283
+ if not self.is_installed():
284
+ return "not installed"
285
+
286
+ result = self.run("--version", check=True)
287
+ return result.stdout.strip()
288
+
289
+
290
+ # Global singleton instance
291
+ _railpack_manager: Optional[RailpackManager] = None
292
+
293
+
294
+ def get_railpack_manager(version: Optional[str] = None) -> RailpackManager:
295
+ """
296
+ Get or create global RailpackManager instance.
297
+
298
+ Args:
299
+ version: Specific version to use. If None, uses default.
300
+
301
+ Returns:
302
+ RailpackManager singleton
303
+ """
304
+ global _railpack_manager
305
+
306
+ if _railpack_manager is None:
307
+ _railpack_manager = RailpackManager(version=version)
308
+ elif version is not None and _railpack_manager.version != version:
309
+ # Requested different version, create new instance
310
+ _railpack_manager = RailpackManager(version=version)
311
+
312
+ return _railpack_manager
xenfra_sdk/recipes.py CHANGED
@@ -1,26 +1,159 @@
1
- from pathlib import Path
1
+ from typing import Dict, Any
2
+ import os
2
3
 
3
- from jinja2 import Environment, FileSystemLoader
4
+ def generate_stack(context: Dict[str, Any], is_dockerized: bool = True) -> str:
5
+ """
6
+ Generates the cloud-init startup script programmatically.
7
+ Effectively replaces the old 'cloud-init.sh.j2' template.
8
+ """
9
+ domain = context.get("domain")
10
+ email = context.get("email")
11
+ port = context.get("port", 8000)
12
+ install_caddy = context.get("install_caddy", False)
13
+ xenfra_api_url = os.getenv("XENFRA_API_URL", "https://api.xenfra.tech")
14
+
15
+ # Base Script Header
16
+ script = [
17
+ "#!/bin/bash",
18
+ "export DEBIAN_FRONTEND=noninteractive",
19
+ 'LOG="/root/setup.log"',
20
+ "touch $LOG",
21
+ "",
22
+ 'echo "--------------------------------" >> $LOG',
23
+ 'echo "🧘 XENFRA: Context-Aware Boot" >> $LOG',
24
+ 'echo "--------------------------------" >> $LOG',
25
+ "",
26
+ "# Create App Directory",
27
+ "mkdir -p /root/app",
28
+ "cd /root/app",
29
+ "",
30
+ "# --- MERCILESS FIX: TERMINATE BACKGROUND PROCESSES ---",
31
+ 'echo "⚔️ [0/6] Mercilessly Terminating Background Processes..." >> $LOG',
32
+ "",
33
+ "kill_apt_processes() {",
34
+ ' echo "🎯 Killing processes holding apt/dpkg locks..." >> $LOG',
35
+ " fuser -k /var/lib/dpkg/lock >/dev/null 2>&1",
36
+ " fuser -k /var/lib/apt/lists/lock >/dev/null 2>&1",
37
+ " fuser -k /var/lib/dpkg/lock-frontends >/dev/null 2>&1",
38
+ "}",
39
+ "",
40
+ "# Explicitly stop and disable services that cause locks",
41
+ "systemctl stop unattended-upgrades.service || true",
42
+ "systemctl disable unattended-upgrades.service || true",
43
+ "systemctl stop apt-daily.service || true",
44
+ "systemctl disable apt-daily.service || true",
45
+ "systemctl stop apt-daily-upgrade.service || true",
46
+ "systemctl disable apt-daily-upgrade.service || true",
47
+ "",
48
+ "# Forcefully kill any remaining lock holders",
49
+ "kill_apt_processes",
50
+ "",
51
+ "# Force remove locks if they still exist (The Nuclear Option)",
52
+ "rm -f /var/lib/dpkg/lock*",
53
+ "rm -f /var/lib/apt/lists/lock*",
54
+ "rm -f /var/cache/apt/archives/lock",
55
+ "dpkg --configure -a || true",
56
+ "# -----------------------------------------------",
57
+ "",
58
+ "# 1. System Updates",
59
+ 'echo "🔄 [1/5] Refreshing Package Lists..." >> $LOG',
60
+ "apt-get update",
61
+ "apt-get install -y python3-pip git curl",
62
+ ""
63
+ ]
4
64
 
65
+ # 2. Setup Environment
66
+ if is_dockerized:
67
+ script.extend([
68
+ 'echo "🐳 [2/5] Installing Docker..." >> $LOG',
69
+ "apt-get install -y docker.io || (curl -fsSL https://get.docker.com | sh)",
70
+ 'echo "🎶 [3/5] Installing Docker Compose..." >> $LOG',
71
+ "apt-get install -y docker-compose-v2",
72
+ "",
73
+ "# Install Railpack (Railway's zero-config builder)",
74
+ 'echo "🚂 [3.5/5] Installing Railpack..." >> $LOG',
75
+ "curl -sSL https://railpack.com/install.sh | sh || echo 'Railpack install failed (non-critical)' >> $LOG",
76
+ ])
77
+ else:
78
+ script.extend([
79
+ 'echo "🐍 [2/5] Setting up host-based Python environment..." >> $LOG',
80
+ "apt-get install -y python3-venv python3-dev build-essential"
81
+ ])
82
+
83
+ script.append("")
5
84
 
6
- def generate_stack(context: dict, is_dockerized: bool = True):
7
- """
8
- Generates a cloud-init startup script from a Jinja2 template.
85
+ # 3. Setup Reverse Proxy
86
+ if is_dockerized or install_caddy:
87
+ script.extend([
88
+ 'echo "📦 [3/5] Installing Caddy..." >> $LOG',
89
+ "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https",
90
+ "curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
91
+ "curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list",
92
+ "apt-get update",
93
+ "apt-get install -y caddy"
94
+ ])
95
+ else:
96
+ script.append('echo "🛡️ [3/5] Skipping Caddy for host deployment (setup manual reverse proxy if needed)." >> $LOG')
97
+
98
+ script.append("")
9
99
 
10
- Args:
11
- context: A dictionary containing information for rendering the template,
12
- e.g., {'domain': 'example.com', 'email': 'user@example.com'}
13
- is_dockerized: Whether to setup Docker and Docker Compose (default: True)
14
- """
15
- # Path to the templates directory
16
- template_dir = Path(__file__).parent / "templates"
17
- env = Environment(loader=FileSystemLoader(template_dir))
100
+ # Caddyfile Config (if domain provided)
101
+ if domain:
102
+ script.extend([
103
+ f'echo "🔒 Writing Caddyfile for {domain}..." >> $LOG',
104
+ "cat << EOF > /etc/caddy/Caddyfile",
105
+ f"{domain}:80, {domain}:443 {{",
106
+ f" reverse_proxy localhost:{port}",
107
+ f" tls {email}",
108
+ "}",
109
+ "EOF",
110
+ "",
111
+ 'echo "🚀 [5/5] Starting Caddy..." >> $LOG',
112
+ "systemctl restart caddy"
113
+ ])
114
+ else:
115
+ script.append('echo "✅ [5/5] Skipping Caddy start (no domain specified)." >> $LOG')
116
+
117
+ script.append("")
118
+
119
+ # Heartbeat Logic
120
+ script.append("# --- ZEN GAP FIX: HEARTBEAT CALLBACK ---")
121
+ script.append('echo "📡 [HEARTBEAT] Sending network_ready signal to Xenfra..." >> $LOG')
122
+
123
+ if xenfra_api_url:
124
+ script.extend([
125
+ 'DROPLET_ID=$(curl -sf http://169.254.169.254/metadata/v1/id 2>/dev/null || echo "")',
126
+ 'DROPLET_IP=$(curl -sf http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || echo "")',
127
+ 'if [ -n "$DROPLET_ID" ]; then',
128
+ f' curl -sf -X POST "{xenfra_api_url}/internal/heartbeat" \\',
129
+ ' -H "Content-Type: application/json" \\',
130
+ ' -d "{\\"droplet_id\\": $DROPLET_ID, \\"status\\": \\"network_ready\\", \\"ip_address\\": \\"$DROPLET_IP\\"}" \\',
131
+ ' --connect-timeout 5 --max-time 10 >> $LOG 2>&1 || echo "⚠️ Heartbeat failed (non-critical)" >> $LOG',
132
+ 'else',
133
+ ' echo "⚠️ [HEARTBEAT] Could not fetch droplet ID from metadata" >> $LOG',
134
+ 'fi'
135
+ ])
136
+ else:
137
+ script.append('echo "⚠️ [HEARTBEAT] Skipped - no API URL configured" >> $LOG')
18
138
 
19
- template = env.get_template("cloud-init.sh.j2")
139
+ # Footer
140
+ script.extend([
141
+ "",
142
+ "# Finish",
143
+ 'echo "✅ SETUP SCRIPT COMPLETE" >> $LOG',
144
+ "touch /root/setup_complete",
145
+ "",
146
+ "# Final heartbeat: cloud-init complete"
147
+ ])
20
148
 
21
- # The context will contain all necessary variables for the template.
22
- # Pass is_dockerized to the template for conditional setup
23
- render_context = {**context, "is_dockerized": is_dockerized}
24
- script = template.render(render_context)
149
+ if xenfra_api_url:
150
+ script.extend([
151
+ 'if [ -n "$DROPLET_ID" ]; then',
152
+ f' curl -sf -X POST "{xenfra_api_url}/internal/heartbeat" \\',
153
+ ' -H "Content-Type: application/json" \\',
154
+ ' -d "{\\"droplet_id\\": $DROPLET_ID, \\"status\\": \\"cloud_init_complete\\", \\"ip_address\\": \\"$DROPLET_IP\\"}" \\',
155
+ ' --connect-timeout 5 --max-time 10 >> $LOG 2>&1 || true',
156
+ 'fi'
157
+ ])
25
158
 
26
- return script
159
+ return "\n".join(script)
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from typing import List
3
+
4
+ from ..exceptions import XenfraAPIError, XenfraError
5
+ from ..models import ActivityLog
6
+ from ..utils import safe_get_json_field, safe_json_parse
7
+ from .base import BaseManager
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ActivityManager(BaseManager):
13
+ def list(self) -> List[ActivityLog]:
14
+ """Retrieves user activity logs."""
15
+ try:
16
+ response = self._client._request("GET", "/users/activity")
17
+ logger.debug(f"ActivityManager.list response: {response.status_code}")
18
+
19
+ data = safe_json_parse(response)
20
+ # Most listing endpoints return a list directly or wrapped in a dict
21
+ if isinstance(data, list):
22
+ logs = data
23
+ else:
24
+ logs = safe_get_json_field(data, "activity", [])
25
+
26
+ if not isinstance(logs, list):
27
+ raise XenfraError(f"Expected list of logs, got {type(logs).__name__}")
28
+
29
+ return [ActivityLog(**log) for log in logs]
30
+ except XenfraAPIError:
31
+ raise
32
+ except Exception as e:
33
+ raise XenfraError(f"Failed to fetch activity logs: {e}")
34
+
35
+ def create(self, action: str, details: str) -> ActivityLog:
36
+ """Internal helper to log activity via the SDK."""
37
+ try:
38
+ payload = {"action": action, "details": details}
39
+ response = self._client._request("POST", "/users/activity", json=payload)
40
+ data = safe_json_parse(response)
41
+ return ActivityLog(**data)
42
+ except XenfraAPIError:
43
+ raise
44
+ except Exception as e:
45
+ raise XenfraError(f"Failed to log activity: {e}")