aeonic-agentguard-sdk-python 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.
aeonic/core/sender.py ADDED
@@ -0,0 +1,234 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import threading
5
+ import urllib.request
6
+ from collections import deque
7
+ from typing import Any, Dict, Optional
8
+
9
+ logger = logging.getLogger("aeonic")
10
+
11
+ # Try to load .env file if python-dotenv is available (optional dependency)
12
+ try:
13
+ from dotenv import load_dotenv # type: ignore
14
+
15
+ # Load .env file from current working directory or project root
16
+ load_dotenv()
17
+ except ImportError:
18
+ # python-dotenv not installed, skip .env loading
19
+ pass
20
+
21
+ # Global state matching TS implementation
22
+ queue: deque = deque()
23
+ sending = False
24
+ sending_lock = threading.Lock()
25
+
26
+
27
+ def _get_endpoint() -> str:
28
+ """
29
+ Get the Aeonic endpoint from environment variable.
30
+ Reads AEONIC_ENDPOINT, with fallback to production endpoint.
31
+ This is called lazily to ensure environment variables are loaded.
32
+ """
33
+ return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
34
+
35
+
36
+ def detect_origin() -> Optional[str]:
37
+ """
38
+ Detect the origin URL from environment variables
39
+ Checks ORIGIN, BASE_URL, or constructs from HOST/HOSTNAME and PORT
40
+ """
41
+ # Check for explicit origin/base URL
42
+ if os.environ.get("ORIGIN"):
43
+ return os.environ["ORIGIN"]
44
+ if os.environ.get("BASE_URL"):
45
+ return os.environ["BASE_URL"]
46
+
47
+ # Construct from HOST/HOSTNAME and PORT
48
+ host = os.environ.get("HOST") or os.environ.get("HOSTNAME") or "localhost"
49
+ port = os.environ.get("PORT")
50
+
51
+ if port:
52
+ # Determine protocol (default to http, use https if port is 443 or HTTPS env is set)
53
+ is_https = os.environ.get("HTTPS") == "true" or port == "443"
54
+ protocol = "https" if is_https else "http"
55
+ return f"{protocol}://{host}:{port}"
56
+
57
+ # If no port, return just the host (might be a domain like api.abcd.com)
58
+ if host != "localhost":
59
+ is_https = os.environ.get("HTTPS") == "true"
60
+ protocol = "https" if is_https else "http"
61
+ return f"{protocol}://{host}"
62
+
63
+ return None
64
+
65
+
66
+ def _is_2xx(status_code: int) -> bool:
67
+ """Return True if status_code is in the 2xx range."""
68
+ return 200 <= status_code < 300
69
+
70
+
71
+ def enqueue(payload: Dict[str, Any]) -> None:
72
+ """Add payload to queue and trigger processing (for 2xx samples → /sdk/agent-samples)."""
73
+ queue.append(payload)
74
+ _process_queue()
75
+
76
+
77
+ def emit_error_sample(payload: Dict[str, Any]) -> None:
78
+ """
79
+ Emit a single error sample (non-2xx response) to /sdk/agent-error-samples in real time.
80
+ Does not buffer; sends immediately in a background thread so the request is not blocked.
81
+ """
82
+
83
+ def _send() -> None:
84
+ try:
85
+ origin = detect_origin()
86
+ headers = {"Content-Type": "application/json"}
87
+ if origin:
88
+ headers["origin"] = origin
89
+ headers["referer"] = origin
90
+ url = f"{_get_endpoint()}/sdk/agent-error-samples"
91
+ data = json.dumps(payload).encode("utf-8")
92
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
93
+ with urllib.request.urlopen(req, timeout=100) as response:
94
+ response_data = json.loads(response.read())
95
+ if response_data.get("success"):
96
+ if os.environ.get("AEONIC_DEBUG"):
97
+ print("[Aeonic] Agent error sample emitted successfully")
98
+ else:
99
+ if os.environ.get("AEONIC_DEBUG"):
100
+ print(
101
+ f"[Aeonic] Failed to emit agent error sample: {response_data.get('message')}"
102
+ )
103
+ except Exception as err:
104
+ if os.environ.get("AEONIC_DEBUG"):
105
+ print(f"[Aeonic] Failed to emit agent error sample: {err}")
106
+
107
+ t = threading.Thread(target=_send)
108
+ t.daemon = True
109
+ t.start()
110
+
111
+
112
+ def _process_queue() -> None:
113
+ """
114
+ Trigger the background processing loop.
115
+ Mimics the recursive/async processQueue in TS.
116
+ """
117
+ global sending
118
+
119
+ # Check if already sending or queue empty
120
+ # We use a lock to ensure thread-safety of the 'sending' flag check/set sequence
121
+ # if we were strictly launching threads, but here we just check if we need to start a worker.
122
+ if sending or not queue:
123
+ return
124
+
125
+ # Start a background thread to process the queue
126
+ # This prevents blocking the main thread (like async usually implies)
127
+ t = threading.Thread(target=_process_queue_worker)
128
+ t.daemon = True
129
+ t.start()
130
+
131
+
132
+ def _process_queue_worker() -> None:
133
+ """Background worker to drain the queue."""
134
+ global sending
135
+
136
+ # helper to acquire lock and set sending
137
+ with sending_lock:
138
+ if sending:
139
+ return
140
+ sending = True
141
+
142
+ try:
143
+ while True:
144
+ # Get next payload
145
+ try:
146
+ # TS: const payload = queue.shift();
147
+ payload = queue.popleft()
148
+ except IndexError:
149
+ # Queue is empty
150
+ break
151
+
152
+ try:
153
+ # Detect origin and referer from environment
154
+ origin = detect_origin()
155
+ headers = {"Content-Type": "application/json"}
156
+
157
+ if origin:
158
+ headers["origin"] = origin
159
+ headers["referer"] = origin
160
+
161
+ url = f"{_get_endpoint()}/sdk/agent-samples"
162
+ data = json.dumps(payload).encode("utf-8")
163
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
164
+
165
+ # TS: await axios.post(...)
166
+ # 10s timeout to avoid hanging indefinitely
167
+ with urllib.request.urlopen(req, timeout=100) as response:
168
+ response_data = json.loads(response.read())
169
+ if response_data.get("success"):
170
+ print("[Aeonic] Agent samples emitted successfully")
171
+ else:
172
+ print(
173
+ f"[Aeonic] Failed to emit agent samples: {response_data.get('message')}"
174
+ )
175
+
176
+ except Exception as err:
177
+ if os.environ.get("AEONIC_DEBUG"):
178
+ # TS: console.error(...)
179
+ print(f"[Aeonic] Failed to emit agent samples: {err}")
180
+
181
+ # retry later (simple strategy for v1)
182
+ # TS: queue.push(payload!)
183
+ queue.append(payload)
184
+ # Prevent tight loop in case of persistent error?
185
+ # TS implementation doesn't sleep, but in Python thread it might consume CPU.
186
+ # Use a small sleep? User asked for "behhave same... nothing else".
187
+ # I will trust the "same" requirement strictly.
188
+
189
+ finally:
190
+ with sending_lock:
191
+ sending = False
192
+
193
+ # Determine if we need to restart (if items added while exiting)
194
+ if queue:
195
+ _process_queue()
196
+
197
+
198
+ def emit_agent_inventory(payload: Dict[str, Any]) -> bool:
199
+ """
200
+ Emit agent inventory to the backend
201
+ This is sent ONCE per process lifecycle
202
+ Does NOT block startup and does NOT throw on failure
203
+ """
204
+ try:
205
+ # Detect origin and referer from environment
206
+ origin = detect_origin()
207
+ headers = {"Content-Type": "application/json"}
208
+
209
+ if origin:
210
+ headers["origin"] = origin
211
+ headers["referer"] = origin
212
+
213
+ url = f"{_get_endpoint()}/sdk/agent-inventory"
214
+ data = json.dumps(payload).encode("utf-8")
215
+
216
+ print(f"[Aeonic] Emitting agent inventory to {url}")
217
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
218
+
219
+ with urllib.request.urlopen(req, timeout=100) as response:
220
+ response_data = json.loads(response.read())
221
+ if response_data.get("success"):
222
+ print("[Aeonic] Agent inventory emitted successfully")
223
+ return True
224
+ else:
225
+ print(f"[Aeonic] Failed to emit agent inventory: {response_data.get('message')}")
226
+ return False
227
+
228
+ except Exception as err:
229
+ # Silently fail - do NOT break startup
230
+ # Log for debugging purposes only
231
+ if os.environ.get("AEONIC_DEBUG"):
232
+ print(f"[Aeonic] Failed to emit agent inventory: {err}")
233
+
234
+ raise err
aeonic/core/types.py ADDED
@@ -0,0 +1,51 @@
1
+ from typing import Any, Dict, List, Optional, TypedDict
2
+
3
+
4
+ class AgentContext(TypedDict, total=False):
5
+ name: str
6
+ type: str
7
+ model: List[str]
8
+ source: str
9
+
10
+
11
+ class RouteContext(TypedDict):
12
+ path: str
13
+ method: str
14
+
15
+
16
+ class Sample(TypedDict, total=False):
17
+ req: Optional[Dict[str, Any]]
18
+ res: Optional[Dict[str, Any]]
19
+ timestamp: int
20
+ status_code: Optional[int] # HTTP status; included for error samples, optional for 2xx
21
+
22
+
23
+ class FlushPayload(TypedDict):
24
+ api_key: str
25
+ service_name: Optional[str]
26
+ agent: AgentContext
27
+ route: RouteContext
28
+ samples: List[Sample]
29
+
30
+
31
+ class AgentDescriptor(TypedDict, total=False):
32
+ """Describes a registered agent with metadata."""
33
+
34
+ name: str
35
+ type: Optional[str]
36
+ model: Optional[List[str]]
37
+ route_path: Optional[str]
38
+ http_method: Optional[str]
39
+ framework: str
40
+ service_name: Optional[str]
41
+ declared_at: float
42
+
43
+
44
+ class InventoryPayload(TypedDict):
45
+ """Payload for agent inventory emission."""
46
+
47
+ event: str
48
+ api_key: str
49
+ service_name: Optional[str]
50
+ framework: str
51
+ agents: List[AgentDescriptor]
@@ -0,0 +1 @@
1
+ """Shared utility helpers for the Aeonic SDK."""
@@ -0,0 +1,22 @@
1
+ # aeonic/utils/safe_serialize.py
2
+
3
+ from typing import Any
4
+
5
+
6
+ def safe_serialize(value: Any) -> Any:
7
+ try:
8
+ if value is None:
9
+ return None
10
+
11
+ if isinstance(value, (str, int, float, bool)):
12
+ return value
13
+
14
+ if isinstance(value, dict):
15
+ return {k: safe_serialize(v) for k, v in value.items()}
16
+
17
+ if isinstance(value, list):
18
+ return [safe_serialize(v) for v in value]
19
+
20
+ return str(value)
21
+ except Exception:
22
+ return None