fustor-sender-http 0.8.1__tar.gz

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.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: fustor-sender-http
3
+ Version: 0.8.1
4
+ Summary: Fustor HTTP Sender - Transport layer for Agent to Fusion communication
5
+ Author-email: Huajin Wang <wanghuajin999@163.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/excelwang/fustor/tree/master/extensions/sender-http
8
+ Project-URL: Bug Tracker, https://github.com/excelwang/fustor/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: fustor-core
14
+ Requires-Dist: fustor-fusion-sdk
15
+
16
+ # fustor-sender-http
17
+
18
+ HTTP Sender for Fustor Agent - implements the transport layer for Agent to Fusion communication.
19
+
20
+ ## Overview
21
+
22
+ This package provides an HTTP-based implementation of the `Sender` transport abstraction. It uses the Fusion SDK client to communicate with Fusion's REST API.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install fustor-sender-http
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from fustor_sender_http import HTTPSender
34
+
35
+ sender = HTTPSender(
36
+ sender_id="my-sender",
37
+ endpoint="http://fusion.example.com:8000",
38
+ credential={"api_key": "your-api-key"}
39
+ )
40
+
41
+ # Create session
42
+ await sender.connect()
43
+ session = await sender.create_session("my-task-id")
44
+
45
+ # Send events
46
+ await sender.send_events(events, source_type="message")
47
+
48
+ # Heartbeat
49
+ await sender.heartbeat()
50
+
51
+ # Cleanup
52
+ await sender.close()
53
+ ```
54
+
55
+ ## Entry Points
56
+
57
+ This package registers itself as:
58
+ - `fustor.senders:http` - New sender registry
59
+ - `fustor_agent.drivers.senders:fusion` - Legacy sender registry (backward compat)
60
+
61
+ ## Migration from fustor-sender-fusion
62
+
63
+ The `fustor-sender-fusion` package is deprecated. To migrate:
64
+
65
+ 1. Replace `from fustor_sender_fusion import FusionDriver` with `from fustor_sender_http import HTTPSender`
66
+ 2. Update configuration to use `sender` instead of `sender` terminology
67
+ 3. The `HTTPSender` class implements the new `Sender` interface but maintains API compatibility
@@ -0,0 +1,52 @@
1
+ # fustor-sender-http
2
+
3
+ HTTP Sender for Fustor Agent - implements the transport layer for Agent to Fusion communication.
4
+
5
+ ## Overview
6
+
7
+ This package provides an HTTP-based implementation of the `Sender` transport abstraction. It uses the Fusion SDK client to communicate with Fusion's REST API.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install fustor-sender-http
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ from fustor_sender_http import HTTPSender
19
+
20
+ sender = HTTPSender(
21
+ sender_id="my-sender",
22
+ endpoint="http://fusion.example.com:8000",
23
+ credential={"api_key": "your-api-key"}
24
+ )
25
+
26
+ # Create session
27
+ await sender.connect()
28
+ session = await sender.create_session("my-task-id")
29
+
30
+ # Send events
31
+ await sender.send_events(events, source_type="message")
32
+
33
+ # Heartbeat
34
+ await sender.heartbeat()
35
+
36
+ # Cleanup
37
+ await sender.close()
38
+ ```
39
+
40
+ ## Entry Points
41
+
42
+ This package registers itself as:
43
+ - `fustor.senders:http` - New sender registry
44
+ - `fustor_agent.drivers.senders:fusion` - Legacy sender registry (backward compat)
45
+
46
+ ## Migration from fustor-sender-fusion
47
+
48
+ The `fustor-sender-fusion` package is deprecated. To migrate:
49
+
50
+ 1. Replace `from fustor_sender_fusion import FusionDriver` with `from fustor_sender_http import HTTPSender`
51
+ 2. Update configuration to use `sender` instead of `sender` terminology
52
+ 3. The `HTTPSender` class implements the new `Sender` interface but maintains API compatibility
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "setuptools-scm>=8.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fustor-sender-http"
7
+ dynamic = ["version"]
8
+ description = "Fustor HTTP Sender - Transport layer for Agent to Fusion communication"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "fustor-core",
18
+ "fustor-fusion-sdk",
19
+ ]
20
+
21
+ [[project.authors]]
22
+ name = "Huajin Wang"
23
+ email = "wanghuajin999@163.com"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/excelwang/fustor/tree/master/extensions/sender-http"
27
+ "Bug Tracker" = "https://github.com/excelwang/fustor/issues"
28
+
29
+ # New entry point for V2 architecture
30
+ [project.entry-points."fustor_agent.drivers.senders"]
31
+ fusion = "fustor_sender_http:HTTPSender"
32
+
33
+
34
+
35
+ [tool.setuptools_scm]
36
+ root = "../.."
37
+ version_scheme = "post-release"
38
+ local_scheme = "dirty-tag"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,233 @@
1
+ """
2
+ Fustor HTTP Sender - Transport layer for Agent to Fusion communication.
3
+
4
+ This package implements the HTTP transport protocol for sending events
5
+ from Fustor Agent to Fustor Fusion.
6
+ """
7
+ try:
8
+ from ._version import version as __version__
9
+ except ImportError:
10
+ __version__ = "unknown"
11
+
12
+ import logging
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import httpx
16
+ from fustor_core.transport import Sender
17
+ from fustor_core.exceptions import FusionConnectionError
18
+ from fustor_core.event import EventBase
19
+ from fustor_core.exceptions import SessionObsoletedError
20
+
21
+
22
+ class HTTPSender(Sender):
23
+ """
24
+ HTTP-based Sender implementation for Fustor.
25
+
26
+ Uses the Fusion SDK client to communicate with Fusion's REST API.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ sender_id: str,
32
+ endpoint: str,
33
+ credential: Dict[str, Any],
34
+ config: Optional[Dict[str, Any]] = None
35
+ ):
36
+ super().__init__(sender_id, endpoint, credential, config)
37
+ self.logger = logging.getLogger(f"fustor.sender.http.{sender_id}")
38
+
39
+ # Lazy import to avoid circular dependency
40
+ from fustor_fusion_sdk.client import FusionClient
41
+
42
+ api_key = credential.get("key") or credential.get("api_key")
43
+
44
+ # Extended configuration
45
+ timeout = self.config.get("timeout", 30.0)
46
+ api_version = self.config.get("api_version", "pipe")
47
+
48
+ self.client = FusionClient(
49
+ base_url=endpoint,
50
+ api_key=api_key,
51
+ timeout=timeout,
52
+ api_version=api_version
53
+ )
54
+
55
+ async def connect(self) -> None:
56
+ """Establish connection (for HTTP, this is a no-op as we use stateless requests)."""
57
+ self.logger.debug(f"HTTP Sender {self.id} ready for endpoint {self.endpoint}")
58
+
59
+ async def create_session(
60
+ self,
61
+ task_id: str,
62
+ source_type: Optional[str] = None,
63
+ session_timeout_seconds: Optional[int] = None,
64
+ **kwargs
65
+ ) -> Dict[str, Any]:
66
+ """
67
+ Create a new session with Fusion.
68
+
69
+ Args:
70
+ task_id: Identifier for this sync task
71
+ source_type: Type of source
72
+ session_timeout_seconds: Requested timeout
73
+
74
+ Returns:
75
+ Session metadata including session_id, timeout, role
76
+ """
77
+ self.logger.info(f"Creating session for task {task_id}...")
78
+ try:
79
+ session_data = await self.client.create_session(
80
+ task_id,
81
+ source_type=source_type,
82
+ session_timeout_seconds=session_timeout_seconds,
83
+ client_info=kwargs
84
+ )
85
+
86
+ if session_data and session_data.get("session_id"):
87
+ session_id = session_data["session_id"]
88
+ self.session_id = session_id
89
+ self.logger.info(
90
+ f"Session created: {self.session_id}, "
91
+ f"Role: {session_data.get('role')}, "
92
+ f"Timeout: {session_data.get('session_timeout_seconds')}s"
93
+ )
94
+ return session_id, session_data
95
+ else:
96
+ # Should not happen if client raises exception on error, but handling just in case
97
+ self.logger.error("Failed to create session: Empty response.")
98
+ raise RuntimeError("Failed to create session with Fusion service: Empty response.")
99
+
100
+ except httpx.ConnectError as e:
101
+ self.logger.warning(f"Failed to create session (Connection Error): {e}")
102
+ raise FusionConnectionError(f"Failed to create session with Fusion service: {e}") from e
103
+ except httpx.HTTPStatusError as e:
104
+ self.logger.error(f"Failed to create session: HTTP {e.response.status_code} - {e.response.text}")
105
+ raise RuntimeError(f"Failed to create session with Fusion service: HTTP {e.response.status_code} - {e.response.text}") from e
106
+ except Exception as e:
107
+ self.logger.error(f"Failed to create session: {e!r}")
108
+ raise RuntimeError(f"Failed to create session with Fusion service: {e}") from e
109
+
110
+ async def _send_events_impl(
111
+ self,
112
+ events: List[EventBase],
113
+ source_type: str = "message",
114
+ is_end: bool = False,
115
+ metadata: Optional[Dict[str, Any]] = None
116
+ ) -> Dict[str, Any]:
117
+ """
118
+ Implementation of sending events to Fusion.
119
+ Called by the base class template method.
120
+ """
121
+ if not self.session_id:
122
+ self.logger.error("Cannot send events: session_id is not set.")
123
+ return {"success": False, "error": "No session"}
124
+
125
+ # Convert events to dictionaries for JSON serialization
126
+ event_dicts = []
127
+ for event in events:
128
+ if hasattr(event, 'model_dump'):
129
+ event_dicts.append(event.model_dump(mode='json'))
130
+ elif isinstance(event, dict):
131
+ event_dicts.append(event)
132
+ else:
133
+ event_dicts.append(dict(event))
134
+
135
+ total_rows = sum(len(e.get("rows", [])) for e in event_dicts)
136
+ self.logger.debug(f"[{source_type}] Attempting to push {len(events)} events ({total_rows} rows) to {self.endpoint}")
137
+
138
+ try:
139
+ success = await self.client.push_events(
140
+ session_id=self.session_id,
141
+ events=event_dicts,
142
+ source_type=source_type,
143
+ is_snapshot_end=is_end,
144
+ metadata=metadata
145
+ )
146
+
147
+ if success:
148
+ self.logger.info(f"[{source_type}] Sent {len(events)} events ({total_rows} rows).")
149
+ return {"success": True}
150
+ else:
151
+ self.logger.error(f"[{source_type}] Failed to send {len(events)} events.")
152
+ return {"success": False, "error": "Push failed"}
153
+ except httpx.HTTPStatusError as e:
154
+ if e.response.status_code == 419:
155
+ raise SessionObsoletedError(f"Session {self.session_id} is obsolete (419)")
156
+ self.logger.error(f"[{source_type}] Failed to send {len(events)} events: {e}")
157
+ return {"success": False, "error": f"Push failed: {e}"}
158
+ except Exception as e:
159
+ self.logger.error(f"[{source_type}] Failed to send {len(events)} events: {e}")
160
+ return {"success": False, "error": "Push failed"}
161
+
162
+ async def heartbeat(self, can_realtime: bool = False) -> Dict[str, Any]:
163
+ """
164
+ Send a heartbeat to maintain session.
165
+
166
+ Returns:
167
+ Response including current role status
168
+ """
169
+ if not self.session_id:
170
+ self.logger.error("Cannot send heartbeat: session_id is not set.")
171
+ return {"status": "error", "message": "Session ID not set"}
172
+
173
+ try:
174
+ result = await self.client.send_heartbeat(self.session_id, can_realtime=can_realtime)
175
+
176
+ if result and result.get("status") == "ok":
177
+ self.logger.debug("Heartbeat sent successfully.")
178
+ return result
179
+ else:
180
+ msg = result.get("message") if result else "Unknown error"
181
+ # Fallback: some legacy or non-FastAPI paths might still return 200 with error body
182
+ if result and result.get("status") == "error":
183
+ raise SessionObsoletedError(f"Session {self.session_id} is no longer valid: {msg}")
184
+ return {"status": "error", "message": msg}
185
+ except httpx.HTTPStatusError as e:
186
+ if e.response.status_code == 419:
187
+ raise SessionObsoletedError(f"Session {self.session_id} is obsolete (419)")
188
+ self.logger.error(f"Failed to send heartbeat: {e}")
189
+ return {"status": "error", "message": f"Heartbeat failed: {e}"}
190
+
191
+ async def close_session(self) -> None:
192
+ """Close the current session gracefully."""
193
+ if self.session_id:
194
+ try:
195
+ await self.client.terminate_session(self.session_id)
196
+ self.logger.info(f"Session {self.session_id} terminated.")
197
+ except Exception as e:
198
+ self.logger.warning(f"Failed to terminate session: {e}")
199
+ finally:
200
+ self.session_id = None
201
+
202
+ async def close(self) -> None:
203
+ """Close the sender and release resources."""
204
+ await self.close_session()
205
+ if hasattr(self.client, 'close'):
206
+ await self.client.close()
207
+
208
+ # --- Consistency signals ---
209
+
210
+ async def signal_audit_start(self) -> bool:
211
+ """Signal the start of an audit cycle."""
212
+ return await self.client.signal_audit_start(self.id)
213
+
214
+ async def signal_audit_end(self) -> bool:
215
+ """Signal the end of an audit cycle."""
216
+ return await self.client.signal_audit_end(self.id)
217
+
218
+ async def get_sentinel_tasks(self) -> Optional[Dict[str, Any]]:
219
+ """Query for sentinel verification tasks."""
220
+ try:
221
+ return await self.client.get_sentinel_tasks()
222
+ except Exception as e:
223
+ self.logger.debug(f"Failed to get sentinel tasks: {e}")
224
+ return None
225
+
226
+ async def submit_sentinel_results(self, results: Dict[str, Any]) -> bool:
227
+ """Submit sentinel verification results."""
228
+ try:
229
+ return await self.client.submit_sentinel_feedback(results)
230
+ except Exception as e:
231
+ self.logger.error(f"Failed to submit sentinel results: {e}")
232
+ return False
233
+
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: fustor-sender-http
3
+ Version: 0.8.1
4
+ Summary: Fustor HTTP Sender - Transport layer for Agent to Fusion communication
5
+ Author-email: Huajin Wang <wanghuajin999@163.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/excelwang/fustor/tree/master/extensions/sender-http
8
+ Project-URL: Bug Tracker, https://github.com/excelwang/fustor/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: fustor-core
14
+ Requires-Dist: fustor-fusion-sdk
15
+
16
+ # fustor-sender-http
17
+
18
+ HTTP Sender for Fustor Agent - implements the transport layer for Agent to Fusion communication.
19
+
20
+ ## Overview
21
+
22
+ This package provides an HTTP-based implementation of the `Sender` transport abstraction. It uses the Fusion SDK client to communicate with Fusion's REST API.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install fustor-sender-http
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from fustor_sender_http import HTTPSender
34
+
35
+ sender = HTTPSender(
36
+ sender_id="my-sender",
37
+ endpoint="http://fusion.example.com:8000",
38
+ credential={"api_key": "your-api-key"}
39
+ )
40
+
41
+ # Create session
42
+ await sender.connect()
43
+ session = await sender.create_session("my-task-id")
44
+
45
+ # Send events
46
+ await sender.send_events(events, source_type="message")
47
+
48
+ # Heartbeat
49
+ await sender.heartbeat()
50
+
51
+ # Cleanup
52
+ await sender.close()
53
+ ```
54
+
55
+ ## Entry Points
56
+
57
+ This package registers itself as:
58
+ - `fustor.senders:http` - New sender registry
59
+ - `fustor_agent.drivers.senders:fusion` - Legacy sender registry (backward compat)
60
+
61
+ ## Migration from fustor-sender-fusion
62
+
63
+ The `fustor-sender-fusion` package is deprecated. To migrate:
64
+
65
+ 1. Replace `from fustor_sender_fusion import FusionDriver` with `from fustor_sender_http import HTTPSender`
66
+ 2. Update configuration to use `sender` instead of `sender` terminology
67
+ 3. The `HTTPSender` class implements the new `Sender` interface but maintains API compatibility
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/fustor_sender_http/__init__.py
4
+ src/fustor_sender_http.egg-info/PKG-INFO
5
+ src/fustor_sender_http.egg-info/SOURCES.txt
6
+ src/fustor_sender_http.egg-info/dependency_links.txt
7
+ src/fustor_sender_http.egg-info/entry_points.txt
8
+ src/fustor_sender_http.egg-info/requires.txt
9
+ src/fustor_sender_http.egg-info/top_level.txt
10
+ tests/test_http_sender.py
@@ -0,0 +1,2 @@
1
+ [fustor_agent.drivers.senders]
2
+ fusion = fustor_sender_http:HTTPSender
@@ -0,0 +1,2 @@
1
+ fustor-core
2
+ fustor-fusion-sdk
@@ -0,0 +1 @@
1
+ fustor_sender_http
@@ -0,0 +1,108 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, MagicMock, patch
3
+ from fustor_sender_http import HTTPSender
4
+ from fustor_core.event import EventBase
5
+ from fustor_core.exceptions import SessionObsoletedError
6
+ import httpx
7
+
8
+ class MockEvent(EventBase):
9
+ def model_dump(self, mode=None):
10
+ return {"id": 1, "data": "test"}
11
+
12
+ @pytest.fixture
13
+ def mock_fusion_client():
14
+ with patch("fustor_fusion_sdk.client.FusionClient") as MockClient:
15
+ client_instance = MockClient.return_value
16
+ client_instance.create_session = AsyncMock()
17
+ client_instance.push_events = AsyncMock()
18
+ client_instance.send_heartbeat = AsyncMock()
19
+ client_instance.terminate_session = AsyncMock()
20
+ client_instance.signal_audit_start = AsyncMock()
21
+ client_instance.signal_audit_end = AsyncMock()
22
+ yield client_instance
23
+
24
+ @pytest.fixture
25
+ def sender(mock_fusion_client):
26
+ credential = {"api_key": "test-key"}
27
+ return HTTPSender("test-sender", "http://localhost", credential)
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_init(sender, mock_fusion_client):
31
+ assert sender.id == "test-sender"
32
+ assert sender.credential["api_key"] == "test-key"
33
+ # Verify client init happened (implicitly via fixture)
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_create_session(sender, mock_fusion_client):
37
+ mock_fusion_client.create_session.return_value = {
38
+ "session_id": "sess-1",
39
+ "role": "leader",
40
+ "session_timeout_seconds": 60
41
+ }
42
+
43
+ session_id, result = await sender.create_session("task-1", "snapshot", 60)
44
+
45
+ assert result["session_id"] == "sess-1"
46
+ assert session_id == "sess-1"
47
+ assert sender.session_id == "sess-1"
48
+ mock_fusion_client.create_session.assert_called_once_with("task-1", source_type="snapshot", session_timeout_seconds=60, client_info={})
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_create_session_failure(sender, mock_fusion_client):
52
+ mock_fusion_client.create_session.return_value = None
53
+
54
+ with pytest.raises(RuntimeError, match="Failed to create session"):
55
+ await sender.create_session("task-1")
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_send_events_success(sender, mock_fusion_client):
59
+ sender.session_id = "sess-1"
60
+ mock_fusion_client.push_events.return_value = True
61
+ events = [{"event_type": "INSERT", "table": "t", "rows": []}]
62
+
63
+ result = await sender._send_events_impl(events, "message", False)
64
+
65
+ assert result["success"] is True
66
+ mock_fusion_client.push_events.assert_called_once()
67
+ args, kwargs = mock_fusion_client.push_events.call_args
68
+ assert kwargs["session_id"] == "sess-1"
69
+ assert len(kwargs["events"]) == 1
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_send_events_no_session(sender):
73
+ # No session_id set
74
+ result = await sender._send_events_impl([], "message")
75
+ assert result["success"] is False
76
+ assert result["error"] == "No session"
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_send_events_obsolete_session(sender, mock_fusion_client):
80
+ sender.session_id = "sess-1"
81
+ # Simulate 419 error
82
+ request = httpx.Request("POST", "http://locahost")
83
+ response = httpx.Response(419, request=request)
84
+ mock_fusion_client.push_events.side_effect = httpx.HTTPStatusError("obsolete", request=request, response=response)
85
+
86
+ with pytest.raises(SessionObsoletedError):
87
+ await sender._send_events_impl([{"event_type": "INSERT", "table": "t", "rows": []}])
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_heartbeat_success(sender, mock_fusion_client):
91
+ sender.session_id = "sess-1"
92
+ mock_fusion_client.send_heartbeat.return_value = {"status": "ok", "role": "follower"}
93
+
94
+ result = await sender.heartbeat()
95
+
96
+ assert result["status"] == "ok"
97
+ mock_fusion_client.send_heartbeat.assert_called_once_with("sess-1", can_realtime=False)
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_audit_signals(sender, mock_fusion_client):
101
+ mock_fusion_client.signal_audit_start.return_value = True
102
+ mock_fusion_client.signal_audit_end.return_value = True
103
+
104
+ assert await sender.signal_audit_start() is True
105
+ assert await sender.signal_audit_end() is True
106
+
107
+ mock_fusion_client.signal_audit_start.assert_called_once_with("test-sender")
108
+ mock_fusion_client.signal_audit_end.assert_called_once_with("test-sender")