sing-box-cli 0.0.25__tar.gz → 0.0.26__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.
Files changed (27) hide show
  1. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/CHANGELOG.md +6 -0
  2. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/PKG-INFO +2 -1
  3. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/pyproject.toml +2 -1
  4. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/__init__.py +1 -1
  5. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/client.py +138 -36
  6. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/connections.py +31 -32
  7. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/monitor.py +22 -25
  8. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/policy.py +37 -43
  9. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/uv.lock +2 -0
  10. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.github/dependabot.yml +0 -0
  11. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.github/workflows/main.yml +0 -0
  12. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.gitignore +0 -0
  13. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.pre-commit-config.yaml +0 -0
  14. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/LICENSE +0 -0
  15. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/README.md +0 -0
  16. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/assets/image.png +0 -0
  17. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/bump.sh +0 -0
  18. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/format.sh +0 -0
  19. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/lint.sh +0 -0
  20. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/pre-commit.sh +0 -0
  21. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/config.py +0 -0
  22. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/main.py +0 -0
  23. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/py.typed +0 -0
  24. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/run.py +0 -0
  25. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/service.py +0 -0
  26. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/widget.py +0 -0
  27. {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/tests/test_config.py +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.0.26 - 2025-03-20
6
+
7
+ ### 🚜 Refactor
8
+
9
+ - Validate responses using pydantic
10
+
5
11
  ## 0.0.25 - 2025-03-20
6
12
 
7
13
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sing-box-cli
3
- Version: 0.0.25
3
+ Version: 0.0.26
4
4
  Summary: cross-platform sing-box-service manager.
5
5
  Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
6
6
  Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
@@ -42,6 +42,7 @@ Requires-Dist: httpx>=0.28.1
42
42
  Requires-Dist: plotext>=5.3.2
43
43
  Requires-Dist: prompt-toolkit>=3.0.50
44
44
  Requires-Dist: psutil>=6.1.1
45
+ Requires-Dist: pydantic>=2.10.6
45
46
  Requires-Dist: typer>=0.15.1
46
47
  Description-Content-Type: text/markdown
47
48
 
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "plotext>=5.3.2",
29
29
  "prompt-toolkit>=3.0.50",
30
30
  "psutil>=6.1.1",
31
+ "pydantic>=2.10.6",
31
32
  "typer>=0.15.1",
32
33
  ]
33
34
 
@@ -212,7 +213,7 @@ sort_commits = "oldest"
212
213
 
213
214
 
214
215
  [tool.bumpversion]
215
- current_version = "0.0.25"
216
+ current_version = "0.0.26"
216
217
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
217
218
  serialize = ["{major}.{minor}.{patch}"]
218
219
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.0.25"
5
+ __version__ = "0.0.26"
@@ -5,9 +5,102 @@ Provides an async client for interacting with Sing-Box HTTP API.
5
5
 
6
6
  import json
7
7
  from collections.abc import AsyncGenerator
8
- from typing import Any
8
+ from typing import Any, TypeVar
9
9
 
10
10
  import httpx
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+
14
+ class FrozenBaseModel(BaseModel):
15
+ """Base model with frozen configuration."""
16
+
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+
20
+ # Define Pydantic models for API responses
21
+ class TrafficData(FrozenBaseModel):
22
+ """http://base_url/traffic/"""
23
+
24
+ up: int = Field(default=0, description="Upload speed in B/s")
25
+ down: int = Field(default=0, description="Download speed in B/s")
26
+
27
+
28
+ class MemoryData(FrozenBaseModel):
29
+ """http://base_url/memory/"""
30
+
31
+ inuse: int = Field(default=0, description="Memory in use in bytes")
32
+ total: int = Field(default=0, description="Total memory in bytes")
33
+
34
+
35
+ class ConnectionMetadata(FrozenBaseModel):
36
+ destinationIP: str = ""
37
+ destinationPort: str
38
+ dnsMode: str
39
+ host: str = ""
40
+ network: str = Field(description="Network type")
41
+ processPath: str = ""
42
+ sourceIP: str
43
+ sourcePort: str
44
+ type: str = Field(description="Inbound type")
45
+
46
+
47
+ class ConnectionInfo(FrozenBaseModel):
48
+ """https://base_url/connections/"""
49
+
50
+ id: str
51
+ download: int = Field(default=0, description="Download in bytes")
52
+ upload: int = Field(default=0, description="Upload in bytes")
53
+ rule: str = Field(description="Rule name => outbound")
54
+ start: str = Field(description="Start time")
55
+ chains: list[str] = Field(description="Proxy chains list")
56
+ metadata: ConnectionMetadata
57
+
58
+
59
+ class ConnectionData(FrozenBaseModel):
60
+ uploadTotal: int = Field(default=0, description="Total upload in bytes")
61
+ downloadTotal: int = Field(default=0, description="Total download in bytes")
62
+ memory: int = Field(default=0, description="Memory usage in bytes")
63
+ connections: list[ConnectionInfo] = Field(description="List of active connections")
64
+
65
+
66
+ class DelayHistory(FrozenBaseModel):
67
+ time: str = ""
68
+ delay: int = Field(default=0, description="Outbound delay in milliseconds")
69
+
70
+
71
+ class GroupInfo(FrozenBaseModel):
72
+ """http://base_url/group/{group_name}/"""
73
+
74
+ type: str = Field(description="Proxy Group type")
75
+ name: str
76
+ udp: bool = Field(description="UDP support")
77
+ history: list[DelayHistory] = Field(
78
+ description="History of selected proxies", default=[]
79
+ )
80
+ now: str = Field(description="Currently selected proxy")
81
+ all: list[str] = Field(description="List of all outbound proxies")
82
+
83
+
84
+ class GroupsData(FrozenBaseModel):
85
+ """http://base_url/group/"""
86
+
87
+ proxies: list[GroupInfo] = Field(description="List of proxies in the group")
88
+
89
+
90
+ class DelayTestResult(FrozenBaseModel):
91
+ """Delay test result model."""
92
+
93
+ delay: int = Field(default=0, description="Outbound delay in milliseconds")
94
+
95
+
96
+ class EmptyResponse(FrozenBaseModel):
97
+ """Empty response model."""
98
+
99
+ status_code: int
100
+
101
+
102
+ # Generic response type for API methods
103
+ T = TypeVar("T", bound=FrozenBaseModel)
11
104
 
12
105
 
13
106
  class SingBoxAPIClient:
@@ -45,7 +138,7 @@ class SingBoxAPIClient:
45
138
  except httpx.HTTPError:
46
139
  return False
47
140
 
48
- async def _make_request(
141
+ async def _make_request_raw(
49
142
  self,
50
143
  method: str,
51
144
  endpoint: str,
@@ -82,12 +175,24 @@ class SingBoxAPIClient:
82
175
  return dict(response.json())
83
176
  return {}
84
177
 
85
- async def traffic_stream(self) -> AsyncGenerator[dict[str, Any], None]:
178
+ async def _make_request(
179
+ self,
180
+ method: str,
181
+ endpoint: str,
182
+ model_class: type[T],
183
+ params: dict[str, Any] | None = None,
184
+ data: dict[str, Any] | None = None,
185
+ ) -> T:
186
+ """Make a request and return data as a specific model."""
187
+ raw = await self._make_request_raw(method, endpoint, params, data)
188
+ return model_class.model_validate(raw)
189
+
190
+ async def traffic_stream(self) -> AsyncGenerator[TrafficData, None]:
86
191
  """
87
192
  Get traffic statistics as a stream of updates.
88
193
 
89
194
  Yields:
90
- Dictionary containing traffic data (up/down in B/s)
195
+ TrafficData object containing traffic data (up/down in B/s)
91
196
  """
92
197
  url = f"{self.base_url}/traffic"
93
198
 
@@ -99,17 +204,18 @@ class SingBoxAPIClient:
99
204
  async for line in response.aiter_lines():
100
205
  if line.strip(): # Skip empty lines
101
206
  try:
102
- yield json.loads(line)
207
+ data = json.loads(line)
208
+ yield TrafficData.model_validate(data)
103
209
  except json.JSONDecodeError:
104
210
  # Skip invalid JSON lines
105
211
  continue
106
212
 
107
- async def memory_stream(self) -> AsyncGenerator[dict[str, Any], None]:
213
+ async def memory_stream(self) -> AsyncGenerator[MemoryData, None]:
108
214
  """
109
215
  Get memory statistics as a stream of updates.
110
216
 
111
217
  Yields:
112
- Dictionary containing memory data (inuse/total in bytes)
218
+ MemoryData object containing memory data (inuse/total in bytes)
113
219
  """
114
220
  url = f"{self.base_url}/memory"
115
221
 
@@ -121,19 +227,22 @@ class SingBoxAPIClient:
121
227
  async for line in response.aiter_lines():
122
228
  if line.strip(): # Skip empty lines
123
229
  try:
124
- yield json.loads(line)
230
+ data = json.loads(line)
231
+ yield MemoryData.model_validate(data)
125
232
  except json.JSONDecodeError:
126
233
  # Skip invalid JSON lines
127
234
  continue
128
235
 
129
- async def get_connections(self) -> dict[str, Any]:
236
+ async def get_connections(self) -> ConnectionData:
130
237
  """
131
238
  Get current connections.
132
239
 
133
240
  Returns:
134
- Dictionary containing active connections
241
+ ConnectionData object containing active connections
135
242
  """
136
- return await self._make_request("GET", "/connections")
243
+ return await self._make_request(
244
+ "GET", "/connections", model_class=ConnectionData
245
+ )
137
246
 
138
247
  async def close_connection(self, connection_id: str) -> dict[str, Any]:
139
248
  """
@@ -145,7 +254,7 @@ class SingBoxAPIClient:
145
254
  Returns:
146
255
  Response from the API
147
256
  """
148
- return await self._make_request("DELETE", f"/connections/{connection_id}")
257
+ return await self._make_request_raw("DELETE", f"/connections/{connection_id}")
149
258
 
150
259
  async def close_all_connections(self) -> dict[str, Any]:
151
260
  """
@@ -154,18 +263,18 @@ class SingBoxAPIClient:
154
263
  Returns:
155
264
  Response from the API
156
265
  """
157
- return await self._make_request("DELETE", "/connections")
266
+ return await self._make_request_raw("DELETE", "/connections")
158
267
 
159
- async def get_groups(self) -> dict[str, Any]:
268
+ async def get_groups(self) -> GroupsData:
160
269
  """
161
270
  Get all policy groups.
162
271
 
163
272
  Returns:
164
- Dictionary containing policy groups information
273
+ GroupData object containing policy groups information
165
274
  """
166
- return await self._make_request("GET", "/group")
275
+ return await self._make_request("GET", "/group", model_class=GroupsData)
167
276
 
168
- async def get_group(self, group_name: str) -> dict[str, Any]:
277
+ async def get_group(self, group_name: str) -> GroupInfo:
169
278
  """
170
279
  Get information about a specific policy group.
171
280
 
@@ -173,9 +282,11 @@ class SingBoxAPIClient:
173
282
  group_name: Name of the policy group
174
283
 
175
284
  Returns:
176
- Dictionary containing the policy group information
285
+ GroupData object containing the policy group information
177
286
  """
178
- return await self._make_request("GET", f"/group/{group_name}")
287
+ return await self._make_request(
288
+ "GET", f"/group/{group_name}", model_class=GroupInfo
289
+ )
179
290
 
180
291
  async def test_group_delay(
181
292
  self,
@@ -188,14 +299,10 @@ class SingBoxAPIClient:
188
299
 
189
300
  Args:
190
301
  group_name: Name of the policy group
191
- url: URL to test latency against
192
302
  timeout: Timeout in milliseconds
193
-
194
- Returns:
195
- Dictionary containing delay test results
196
303
  """
197
304
  params = {"url": url, "timeout": timeout}
198
- return await self._make_request(
305
+ return await self._make_request_raw(
199
306
  "GET", f"/group/{group_name}/delay", params=params
200
307
  )
201
308
 
@@ -204,21 +311,19 @@ class SingBoxAPIClient:
204
311
  proxy_name: str,
205
312
  url: str = "https://cp.cloudflare.com/generate_204",
206
313
  timeout: int = 5000,
207
- ) -> dict[str, Any]:
314
+ ) -> DelayTestResult:
208
315
  """
209
316
  Test delay for a specific proxy.
210
317
 
211
318
  Args:
212
- proxy_name: Name of the proxy
213
- url: URL to test latency against
214
319
  timeout: Timeout in milliseconds
215
-
216
- Returns:
217
- Dictionary containing delay test results
218
320
  """
219
321
  params = {"url": url, "timeout": timeout}
220
322
  return await self._make_request(
221
- "GET", f"/proxies/{proxy_name}/delay", params=params
323
+ "GET",
324
+ f"/proxies/{proxy_name}/delay",
325
+ params=params,
326
+ model_class=DelayTestResult,
222
327
  )
223
328
 
224
329
  async def select_proxy(self, proxy_name: str, selected: str) -> dict[str, Any]:
@@ -228,12 +333,9 @@ class SingBoxAPIClient:
228
333
  Args:
229
334
  proxy_name: Name of the proxy selector
230
335
  selected: Name of the proxy to select
231
-
232
- Returns:
233
- Response from the API
234
336
  """
235
337
  data = {"name": selected}
236
- return await self._make_request("PUT", f"/proxies/{proxy_name}", data=data)
338
+ return await self._make_request_raw("PUT", f"/proxies/{proxy_name}", data=data)
237
339
 
238
340
  async def get_version(self) -> dict[str, Any]:
239
341
  """
@@ -242,4 +344,4 @@ class SingBoxAPIClient:
242
344
  Returns:
243
345
  Dictionary containing version information
244
346
  """
245
- return await self._make_request("GET", "/version")
347
+ return await self._make_request_raw("GET", "/version")
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- from typing import Any
3
2
 
4
3
  from prompt_toolkit.application import Application
5
4
  from prompt_toolkit.buffer import Buffer
@@ -18,7 +17,7 @@ from rich.panel import Panel
18
17
  from rich.table import Table
19
18
  from rich.text import Text
20
19
 
21
- from .client import SingBoxAPIClient
20
+ from .client import ConnectionData, ConnectionInfo, SingBoxAPIClient
22
21
  from .monitor import (
23
22
  format_bytes,
24
23
  format_chain,
@@ -30,7 +29,7 @@ from .widget import create_header_panel, render_rich_to_prompt_toolkit
30
29
 
31
30
 
32
31
  def create_connections_table(
33
- connections_data: dict[str, Any],
32
+ connections_data: ConnectionData,
34
33
  total: int,
35
34
  highlight_index: int = -1,
36
35
  max_display: int = 20,
@@ -47,10 +46,10 @@ def create_connections_table(
47
46
  Returns:
48
47
  Rich Panel containing the connections information
49
48
  """
50
- if not connections_data or "connections" not in connections_data:
49
+ if len(connections_data.connections) == 0:
51
50
  return Panel("No connections data available", title="Active Connections")
52
51
 
53
- connections = connections_data.get("connections", [])
52
+ connections = connections_data.connections
54
53
 
55
54
  if not connections:
56
55
  return Panel("No active connections", title="Active Connections")
@@ -75,21 +74,21 @@ def create_connections_table(
75
74
  # Show top connections (limit to avoid overwhelming the display)
76
75
  for idx, conn in enumerate(sorted_connections[:max_display]):
77
76
  # Extract data
78
- metadata = conn.get("metadata", {})
79
- host = metadata.get("host", "") or metadata.get("destinationIP", "?")
77
+ metadata = conn.metadata
78
+ host = metadata.host or metadata.destinationIP
80
79
 
81
- network = metadata.get("network", "?").upper()
82
- upload = format_bytes(conn.get("upload", 0))
83
- download = format_bytes(conn.get("download", 0))
80
+ network = metadata.network.upper()
81
+ upload = format_bytes(conn.upload)
82
+ download = format_bytes(conn.download)
84
83
 
85
84
  # Extract rule information
86
- rule_display = format_rule(conn.get("rule", ""))
85
+ rule_display = format_rule(conn.rule)
87
86
 
88
87
  # Calculate duration
89
- duration_str = format_duration(conn.get("start", ""))
88
+ duration_str = format_duration(conn.start)
90
89
 
91
90
  # Format chains (usually shows proxy names)
92
- chain_str = format_chain(conn.get("chains", []))
91
+ chain_str = format_chain(conn.chains)
93
92
 
94
93
  # Determine if this row should be highlighted
95
94
  if idx == highlight_index:
@@ -110,8 +109,8 @@ def create_connections_table(
110
109
  )
111
110
 
112
111
  # Add summary information
113
- total_upload = format_bytes(connections_data.get("uploadTotal", 0))
114
- total_download = format_bytes(connections_data.get("downloadTotal", 0))
112
+ total_upload = format_bytes(connections_data.uploadTotal)
113
+ total_download = format_bytes(connections_data.downloadTotal)
115
114
 
116
115
  summary = f"Total Upload: {total_upload} | Total Download: {total_download}"
117
116
  if len(connections) > max_display:
@@ -129,8 +128,8 @@ class ConnectionsManager:
129
128
  def __init__(self, api_client: SingBoxAPIClient) -> None:
130
129
  """Initialize the connections manager."""
131
130
  self.api_client = api_client
132
- self.connections: list[dict[str, Any]] = []
133
- self.filtered_connections: list[dict[str, Any]] = []
131
+ self.connections: list[ConnectionInfo] = []
132
+ self.filtered_connections: list[ConnectionInfo] = []
134
133
  self.page = 0
135
134
  self.page_size = 20
136
135
  self.selected_index = 0
@@ -160,11 +159,11 @@ class ConnectionsManager:
160
159
  return Style.from_dict({"status-bar": "bg:#333333 #ffffff"})
161
160
 
162
161
  @property
163
- def selected_connection(self) -> dict[str, Any]:
162
+ def selected_connection(self) -> ConnectionInfo:
164
163
  """Get the currently selected connection."""
165
164
  return self.filtered_connections[
166
165
  self.page * self.page_size + self.selected_index
167
- ].copy()
166
+ ].model_copy()
168
167
 
169
168
  def setup_keybindings(self) -> None:
170
169
  """Setup keyboard shortcuts."""
@@ -177,7 +176,7 @@ class ConnectionsManager:
177
176
  @self.kb.add("c-d")
178
177
  def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
179
178
  """Close the connection."""
180
- conn_id = self.selected_connection.get("id")
179
+ conn_id = self.selected_connection.id
181
180
  asyncio.create_task(self.close_connection(str(conn_id)))
182
181
 
183
182
  @self.kb.add("c-c")
@@ -248,19 +247,19 @@ class ConnectionsManager:
248
247
  self.filtered_connections = []
249
248
  for conn in self.connections:
250
249
  # Check host
251
- host = conn.get("metadata", {}).get("host", "").lower()
250
+ host = conn.metadata.host.lower()
252
251
  if self.filter_text in host:
253
252
  self.filtered_connections.append(conn)
254
253
  continue
255
254
 
256
255
  # Check chains
257
- chains = format_chain(conn.get("chains", [])).lower()
256
+ chains = format_chain(conn.chains).lower()
258
257
  if self.filter_text in chains:
259
258
  self.filtered_connections.append(conn)
260
259
  continue
261
260
 
262
261
  # Check network
263
- network = conn.get("metadata", {}).get("network", "").lower()
262
+ network = conn.metadata.network.lower()
264
263
  if self.filter_text in network:
265
264
  self.filtered_connections.append(conn)
266
265
  continue
@@ -359,11 +358,11 @@ class ConnectionsManager:
359
358
  end_idx = min(start_idx + self.page_size, len(self.filtered_connections))
360
359
 
361
360
  # Create connections data for the current page
362
- page_connections = {
363
- "connections": self.filtered_connections[start_idx:end_idx],
364
- "uploadTotal": self.total_upload,
365
- "downloadTotal": self.total_download,
366
- }
361
+ page_connections = ConnectionData(
362
+ connections=self.filtered_connections[start_idx:end_idx],
363
+ uploadTotal=self.total_upload,
364
+ downloadTotal=self.total_download,
365
+ )
367
366
 
368
367
  # Create rich panel with highlighted row
369
368
  panel = create_connections_table(
@@ -380,7 +379,7 @@ class ConnectionsManager:
380
379
  """Close a specific connection."""
381
380
  try:
382
381
  await self.api_client.close_connection(conn_id)
383
- host = self.selected_connection.get("metadata", {}).get("host", "Unknown")
382
+ host = self.selected_connection.metadata.host
384
383
  cnnt_info = f"Connection {host=} {conn_id=}"
385
384
  self.status_message = f"{cnnt_info} closed successfully"
386
385
  # Refresh connections after closing one
@@ -405,9 +404,9 @@ class ConnectionsManager:
405
404
  try:
406
405
  data = await self.api_client.get_connections()
407
406
  # Sort connections by time and host
408
- self.connections = sort_connections(data.get("connections", []))
409
- self.total_upload = data.get("uploadTotal", 0)
410
- self.total_download = data.get("downloadTotal", 0)
407
+ self.connections = sort_connections(data.connections)
408
+ self.total_upload = data.uploadTotal
409
+ self.total_download = data.downloadTotal
411
410
  self.filter_connections() # Apply current filter
412
411
  except Exception as e:
413
412
  self.status_message = f"Error refreshing connections: {str(e)}"
@@ -4,7 +4,6 @@ import statistics
4
4
  from collections import deque
5
5
  from datetime import datetime
6
6
  from enum import Enum
7
- from typing import Any
8
7
 
9
8
  import plotext as plt # type: ignore
10
9
  from rich.console import Console
@@ -14,7 +13,7 @@ from rich.panel import Panel
14
13
  from rich.table import Table
15
14
  from rich.text import Text
16
15
 
17
- from .client import SingBoxAPIClient
16
+ from .client import ConnectionInfo, MemoryData, SingBoxAPIClient, TrafficData
18
17
  from .widget import RichPlotMixin, create_header_panel
19
18
 
20
19
 
@@ -69,7 +68,7 @@ def format_speed(bytes_per_sec_i: int | float) -> str:
69
68
 
70
69
 
71
70
  def calculate_averages(
72
- data_history: deque[dict[str, int]], key: str
71
+ data_history: deque[TrafficData] | deque[MemoryData], key: str
73
72
  ) -> tuple[float, float]:
74
73
  """
75
74
  Calculate 5s and 10s averages for a specific metric.
@@ -88,7 +87,7 @@ def calculate_averages(
88
87
  list(data_history)[-5:] if history_len >= 5 else list(data_history)
89
88
  )
90
89
  five_sec_avg = (
91
- statistics.mean(item.get(key, 0) for item in five_sec_samples)
90
+ statistics.mean(getattr(item, key) for item in five_sec_samples)
92
91
  if five_sec_samples
93
92
  else 0
94
93
  )
@@ -98,7 +97,7 @@ def calculate_averages(
98
97
  list(data_history)[-10:] if history_len >= 10 else list(data_history)
99
98
  )
100
99
  ten_sec_avg = (
101
- statistics.mean(item.get(key, 0) for item in ten_sec_samples)
100
+ statistics.mean(getattr(item, key) for item in ten_sec_samples)
102
101
  if ten_sec_samples
103
102
  else 0
104
103
  )
@@ -106,7 +105,7 @@ def calculate_averages(
106
105
  return five_sec_avg, ten_sec_avg
107
106
 
108
107
 
109
- def sort_connections(connections: list[dict[str, Any]]) -> list[dict[str, Any]]:
108
+ def sort_connections(connections: list[ConnectionInfo]) -> list[ConnectionInfo]:
110
109
  """
111
110
  Sort connections by time in descending order.
112
111
 
@@ -120,9 +119,7 @@ def sort_connections(connections: list[dict[str, Any]]) -> list[dict[str, Any]]:
120
119
  Returns:
121
120
  list[dict[str, Any]]: The sorted list of connection dictionaries.
122
121
  """
123
- return sorted(
124
- connections, key=lambda x: (x.get("start", ""), x.get("host", "")), reverse=True
125
- )
122
+ return sorted(connections, key=lambda x: (x.start, x.metadata.host), reverse=True)
126
123
 
127
124
 
128
125
  def format_rule(rule: str) -> str:
@@ -369,7 +366,7 @@ class TrafficGraph(RichPlotMixin):
369
366
  # Number of points to generate during inte= 5
370
367
  self.interp_factor = 5
371
368
 
372
- def update_from_traffic_data(self, traffic_data: dict[str, Any]) -> None:
369
+ def update_from_traffic_data(self, traffic_data: TrafficData) -> None:
373
370
  """
374
371
  Update the graph with new traffic data.
375
372
 
@@ -378,8 +375,8 @@ class TrafficGraph(RichPlotMixin):
378
375
  """
379
376
 
380
377
  # Update the graph with new data
381
- self.upload_speeds.append(traffic_data.get("up", 0))
382
- self.download_speeds.append(traffic_data.get("down", 0))
378
+ self.upload_speeds.append(traffic_data.up)
379
+ self.download_speeds.append(traffic_data.down)
383
380
 
384
381
  def make_plot(self, width: int, height: int, tension: float = 0.8) -> str:
385
382
  """
@@ -478,22 +475,22 @@ class ResourceVisualizer:
478
475
  self.console = Console()
479
476
 
480
477
  # For averages calculation
481
- self.traffic_data_history: deque[dict[str, int]] = deque(maxlen=10)
482
- self.memory_data_history: deque[dict[str, int]] = deque(maxlen=10)
478
+ self.traffic_data_history: deque[TrafficData] = deque(maxlen=10)
479
+ self.memory_data_history: deque[MemoryData] = deque(maxlen=10)
483
480
 
484
481
  # For traffic graph
485
482
  self.traffic_graph = TrafficGraph(
486
483
  max_points=60 # Store 1 minutes of data
487
484
  )
488
485
 
489
- def create_traffic_table(self, traffic_data: dict[str, Any]) -> Table:
486
+ def create_traffic_table(self, traffic_data: TrafficData) -> Table:
490
487
  """Create a table displaying traffic statistics with averages."""
491
488
  # Add current data to history
492
- self.traffic_data_history.append(traffic_data.copy())
489
+ self.traffic_data_history.append(traffic_data.model_copy())
493
490
 
494
491
  # Get the raw values
495
- up_bytes = traffic_data.get("up", 0)
496
- down_bytes = traffic_data.get("down", 0)
492
+ up_bytes = traffic_data.up
493
+ down_bytes = traffic_data.down
497
494
 
498
495
  # Calculate averages
499
496
  up_5s_avg, up_10s_avg = calculate_averages(self.traffic_data_history, "up")
@@ -523,14 +520,14 @@ class ResourceVisualizer:
523
520
  )
524
521
  return table
525
522
 
526
- def create_memory_table(self, memory_data: dict[str, Any]) -> Table:
523
+ def create_memory_table(self, memory_data: MemoryData) -> Table:
527
524
  """Create a table displaying memory usage statistics with averages."""
528
525
  # Add current data to history
529
- self.memory_data_history.append(memory_data.copy())
526
+ self.memory_data_history.append(memory_data.model_copy())
530
527
 
531
528
  # Extract current memory values
532
- inuse = memory_data.get("inuse", 0)
533
- total = memory_data.get("total", 0)
529
+ inuse = memory_data.inuse
530
+ total = memory_data.total
534
531
 
535
532
  # Calculate averages
536
533
  inuse_5s_avg, inuse_10s_avg = calculate_averages(
@@ -577,7 +574,7 @@ class ResourceVisualizer:
577
574
  return table
578
575
 
579
576
  def create_resources_layout(
580
- self, traffic_data: dict[str, Any], memory_data: dict[str, Any]
577
+ self, traffic_data: TrafficData, memory_data: MemoryData
581
578
  ) -> Layout:
582
579
  """Create the main layout with all resource components."""
583
580
  layout = Layout()
@@ -640,8 +637,8 @@ class ResourceMonitor:
640
637
  self.api_client = api_client
641
638
  self.visualizer = visualizer
642
639
  self.running = False
643
- self.current_traffic = {"up": 0, "down": 0} # in bytes
644
- self.current_memory = {"inuse": 0, "total": 0} # in bytes
640
+ self.current_traffic = TrafficData(up=0, down=0) # in bytes
641
+ self.current_memory = MemoryData(inuse=0, total=0) # in bytes
645
642
  # 1.0 fix interval for avoiding repeated data
646
643
  self.task_interval = RefreshRate.SLOW.value
647
644
 
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- from typing import Any
3
2
 
4
3
  from prompt_toolkit.application import Application
5
4
  from prompt_toolkit.formatted_text import FormattedText
@@ -11,31 +10,27 @@ from rich.panel import Panel
11
10
  from rich.table import Table
12
11
  from rich.text import Text
13
12
 
14
- from .client import SingBoxAPIClient
13
+ from .client import GroupInfo, SingBoxAPIClient
15
14
  from .widget import create_header_panel, render_rich_to_prompt_toolkit
16
15
 
17
16
 
18
17
  def create_policy_group_table(
19
- group_data: dict[str, Any],
18
+ group_data: GroupInfo | None,
20
19
  delay_cache: dict[str, int] | None = None,
21
20
  selected_index: int = -1,
22
21
  ) -> Panel:
23
22
  """Create a table to display policy group information."""
24
- if not group_data or "all" not in group_data:
23
+ if group_data is None:
25
24
  return Panel("No policy group data available", title="Policy Group")
26
25
 
27
- name = group_data.get("name", "Unknown")
28
- type_info = group_data.get("type", "Unknown")
29
- current = group_data.get("now", "")
30
- proxies = group_data.get("all", [])
31
-
26
+ name = group_data.name
27
+ type_info = group_data.type
28
+ current = group_data.now
29
+ proxies = group_data.all
32
30
  # Get delay information, prioritizing the cache
33
- history = group_data.get("history", [])
34
- history_dict = (
35
- {item.get("time", ""): item.get("delay", 0) for item in history}
36
- if history
37
- else {}
38
- )
31
+ # TODO: no sure if this is the best way to handle this
32
+ history = group_data.history
33
+ history_dict = {item.time: item.delay for item in history} if history else {}
39
34
 
40
35
  # Create table for proxies
41
36
  table = Table(expand=True, show_header=True)
@@ -92,10 +87,10 @@ class PolicyGroupManager:
92
87
  def __init__(self, api_client: SingBoxAPIClient) -> None:
93
88
  """Initialize the policy group manager."""
94
89
  self.api_client = api_client
95
- self.groups: list[dict[str, Any]] = []
90
+ self.groups: list[GroupInfo] = []
96
91
  self.selected_group_index = 0
97
92
  self.selected_proxy_index = 0
98
- self.current_group: dict[str, Any] | None = None
93
+ self.current_group: GroupInfo | None = None
99
94
  self.status_message = "Loading policy groups..."
100
95
  self.running = False
101
96
 
@@ -142,8 +137,8 @@ class PolicyGroupManager:
142
137
  self.selected_group_index += 1
143
138
  asyncio.create_task(self.load_selected_group())
144
139
  else:
145
- if self.current_group and "all" in self.current_group:
146
- proxies = self.current_group["all"]
140
+ if self.current_group:
141
+ proxies = self.current_group.all
147
142
  if self.selected_proxy_index < len(proxies) - 1:
148
143
  self.selected_proxy_index += 1
149
144
 
@@ -163,8 +158,8 @@ class PolicyGroupManager:
163
158
  """Select a group or a proxy."""
164
159
  if not self.focus_on_groups and self.current_group:
165
160
  # Select proxy
166
- group_name = self.current_group.get("name", "")
167
- proxies = self.current_group.get("all", [])
161
+ group_name = self.current_group.name
162
+ proxies = self.current_group.all
168
163
  if proxies and self.selected_proxy_index < len(proxies):
169
164
  selected_proxy = proxies[self.selected_proxy_index]
170
165
  asyncio.create_task(self.select_proxy(group_name, selected_proxy))
@@ -173,7 +168,7 @@ class PolicyGroupManager:
173
168
  def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
174
169
  """Test delay for selected proxy."""
175
170
  if not self.focus_on_groups and self.current_group:
176
- proxies = self.current_group.get("all", [])
171
+ proxies = self.current_group.all
177
172
  if proxies and self.selected_proxy_index < len(proxies):
178
173
  selected_proxy = proxies[self.selected_proxy_index]
179
174
  asyncio.create_task(self.test_proxy_delay(selected_proxy))
@@ -182,7 +177,7 @@ class PolicyGroupManager:
182
177
  def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
183
178
  """Test delay for all proxies in the group."""
184
179
  if self.current_group:
185
- group_name = self.current_group.get("name", "")
180
+ group_name = self.current_group.name
186
181
  asyncio.create_task(self.test_group_delay(group_name))
187
182
 
188
183
  def create_layout(self) -> PTLayout:
@@ -251,7 +246,7 @@ class PolicyGroupManager:
251
246
  table.add_column("Policy Groups")
252
247
 
253
248
  for i, group in enumerate(self.groups):
254
- name = group.get("name", "Unknown")
249
+ name = group.name
255
250
  if i == self.selected_group_index:
256
251
  style = "bold white on blue"
257
252
  text = Text(f"{name}", style=style)
@@ -268,7 +263,7 @@ class PolicyGroupManager:
268
263
  if not self.current_group:
269
264
  return Panel("No policy group selected", title="Proxies")
270
265
 
271
- group_name = self.current_group.get("name", "")
266
+ group_name = self.current_group.name
272
267
  group_delay_cache = self.delay_cache.get(group_name, {})
273
268
 
274
269
  return create_policy_group_table(
@@ -283,7 +278,7 @@ class PolicyGroupManager:
283
278
  """Load all policy groups."""
284
279
  try:
285
280
  data = await self.api_client.get_groups()
286
- self.groups = data.get("proxies", [])
281
+ self.groups = data.proxies
287
282
  if self.groups:
288
283
  # Load the first group
289
284
  await self.load_selected_group()
@@ -299,28 +294,27 @@ class PolicyGroupManager:
299
294
  return
300
295
 
301
296
  try:
302
- group_name = self.groups[self.selected_group_index].get("name", "")
297
+ group_name = self.groups[self.selected_group_index].name
303
298
  self.status_message = f"Loading group: {group_name}..."
304
299
 
305
300
  data = await self.api_client.get_group(group_name)
306
301
 
307
302
  # Make sure we have the history data
308
- if "history" not in data and "now" in data:
309
- # Try to fetch delay data if not present
310
- try:
311
- await self.api_client.test_group_delay(group_name)
312
- # Fetch the group data again to get updated delay information
313
- data = await self.api_client.get_group(group_name)
314
- except Exception:
315
- # If testing fails, continue with existing data
316
- pass
303
+ # Try to fetch delay data if not present
304
+ try:
305
+ await self.api_client.test_group_delay(group_name)
306
+ # Fetch the group data again to get updated delay information
307
+ data = await self.api_client.get_group(group_name)
308
+ except Exception:
309
+ # If testing fails, continue with existing data
310
+ pass
317
311
 
318
312
  self.current_group = data
319
313
  self.selected_proxy_index = 0
320
314
 
321
315
  # Find the index of the currently selected proxy
322
- current = data.get("now", "")
323
- proxies = data.get("all", [])
316
+ current = data.now
317
+ proxies = data.all
324
318
  if current in proxies:
325
319
  self.selected_proxy_index = proxies.index(current)
326
320
 
@@ -343,14 +337,14 @@ class PolicyGroupManager:
343
337
  if not self.current_group:
344
338
  return
345
339
 
346
- group_name = self.current_group.get("name", "")
340
+ group_name = self.current_group.name
347
341
 
348
342
  try:
349
343
  self.status_message = f"Testing proxy delay: {proxy_name}..."
350
344
  result = await self.api_client.test_proxy_delay(proxy_name)
351
345
 
352
346
  # Extract delay information
353
- delay = result.get("delay", 0)
347
+ delay = result.delay
354
348
 
355
349
  # Update our delay cache
356
350
  if group_name not in self.delay_cache:
@@ -367,7 +361,7 @@ class PolicyGroupManager:
367
361
  if not self.current_group:
368
362
  return
369
363
 
370
- proxies = self.current_group.get("all", [])
364
+ proxies = self.current_group.all
371
365
  if not proxies:
372
366
  return
373
367
 
@@ -379,10 +373,11 @@ class PolicyGroupManager:
379
373
  self.delay_cache[group_name] = {}
380
374
 
381
375
  # Test each proxy individually to ensure we get all results
376
+ # TODO: Consider batching these requests
382
377
  for proxy in proxies:
383
378
  try:
384
379
  result = await self.api_client.test_proxy_delay(proxy)
385
- delay = result.get("delay", 0)
380
+ delay = result.delay
386
381
  self.delay_cache[group_name][proxy] = delay
387
382
  except Exception:
388
383
  # If one proxy test fails, continue with others
@@ -398,7 +393,6 @@ class PolicyGroupManager:
398
393
 
399
394
  # Initial load of groups
400
395
  await self.load_groups()
401
-
402
396
  # Setup periodic refresh task
403
397
  refresh_task = asyncio.create_task(self.periodic_refresh())
404
398
 
@@ -623,6 +623,7 @@ dependencies = [
623
623
  { name = "plotext", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
624
624
  { name = "prompt-toolkit", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
625
625
  { name = "psutil", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
626
+ { name = "pydantic", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
626
627
  { name = "typer", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
627
628
  ]
628
629
 
@@ -645,6 +646,7 @@ requires-dist = [
645
646
  { name = "plotext", specifier = ">=5.3.2" },
646
647
  { name = "prompt-toolkit", specifier = ">=3.0.50" },
647
648
  { name = "psutil", specifier = ">=6.1.1" },
649
+ { name = "pydantic", specifier = ">=2.10.6" },
648
650
  { name = "typer", specifier = ">=0.15.1" },
649
651
  ]
650
652
 
File without changes
File without changes
File without changes