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.
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/CHANGELOG.md +6 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/PKG-INFO +2 -1
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/pyproject.toml +2 -1
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/__init__.py +1 -1
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/client.py +138 -36
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/connections.py +31 -32
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/monitor.py +22 -25
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/policy.py +37 -43
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/uv.lock +2 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.github/dependabot.yml +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.github/workflows/main.yml +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.gitignore +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/.pre-commit-config.yaml +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/LICENSE +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/README.md +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/assets/image.png +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/bump.sh +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/format.sh +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/lint.sh +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/scripts/pre-commit.sh +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/config.py +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/main.py +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/py.typed +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/run.py +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/service.py +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/src/sing_box_service/widget.py +0 -0
- {sing_box_cli-0.0.25 → sing_box_cli-0.0.26}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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}"
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
236
|
+
async def get_connections(self) -> ConnectionData:
|
|
130
237
|
"""
|
|
131
238
|
Get current connections.
|
|
132
239
|
|
|
133
240
|
Returns:
|
|
134
|
-
|
|
241
|
+
ConnectionData object containing active connections
|
|
135
242
|
"""
|
|
136
|
-
return await self._make_request(
|
|
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.
|
|
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.
|
|
266
|
+
return await self._make_request_raw("DELETE", "/connections")
|
|
158
267
|
|
|
159
|
-
async def get_groups(self) ->
|
|
268
|
+
async def get_groups(self) -> GroupsData:
|
|
160
269
|
"""
|
|
161
270
|
Get all policy groups.
|
|
162
271
|
|
|
163
272
|
Returns:
|
|
164
|
-
|
|
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) ->
|
|
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
|
-
|
|
285
|
+
GroupData object containing the policy group information
|
|
177
286
|
"""
|
|
178
|
-
return await self._make_request(
|
|
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.
|
|
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
|
-
) ->
|
|
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",
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
49
|
+
if len(connections_data.connections) == 0:
|
|
51
50
|
return Panel("No connections data available", title="Active Connections")
|
|
52
51
|
|
|
53
|
-
connections = connections_data.
|
|
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.
|
|
79
|
-
host = metadata.
|
|
77
|
+
metadata = conn.metadata
|
|
78
|
+
host = metadata.host or metadata.destinationIP
|
|
80
79
|
|
|
81
|
-
network = metadata.
|
|
82
|
-
upload = format_bytes(conn.
|
|
83
|
-
download = format_bytes(conn.
|
|
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.
|
|
85
|
+
rule_display = format_rule(conn.rule)
|
|
87
86
|
|
|
88
87
|
# Calculate duration
|
|
89
|
-
duration_str = format_duration(conn.
|
|
88
|
+
duration_str = format_duration(conn.start)
|
|
90
89
|
|
|
91
90
|
# Format chains (usually shows proxy names)
|
|
92
|
-
chain_str = format_chain(conn.
|
|
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.
|
|
114
|
-
total_download = format_bytes(connections_data.
|
|
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[
|
|
133
|
-
self.filtered_connections: list[
|
|
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) ->
|
|
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
|
-
].
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
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.
|
|
409
|
-
self.total_upload = data.
|
|
410
|
-
self.total_download = data.
|
|
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[
|
|
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
|
|
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
|
|
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[
|
|
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:
|
|
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.
|
|
382
|
-
self.download_speeds.append(traffic_data.
|
|
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[
|
|
482
|
-
self.memory_data_history: deque[
|
|
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:
|
|
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.
|
|
489
|
+
self.traffic_data_history.append(traffic_data.model_copy())
|
|
493
490
|
|
|
494
491
|
# Get the raw values
|
|
495
|
-
up_bytes = traffic_data.
|
|
496
|
-
down_bytes = traffic_data.
|
|
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:
|
|
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.
|
|
526
|
+
self.memory_data_history.append(memory_data.model_copy())
|
|
530
527
|
|
|
531
528
|
# Extract current memory values
|
|
532
|
-
inuse = memory_data.
|
|
533
|
-
total = memory_data.
|
|
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:
|
|
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 =
|
|
644
|
-
self.current_memory =
|
|
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:
|
|
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
|
|
23
|
+
if group_data is None:
|
|
25
24
|
return Panel("No policy group data available", title="Policy Group")
|
|
26
25
|
|
|
27
|
-
name = group_data.
|
|
28
|
-
type_info = group_data.
|
|
29
|
-
current = group_data.
|
|
30
|
-
proxies = group_data.
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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[
|
|
90
|
+
self.groups: list[GroupInfo] = []
|
|
96
91
|
self.selected_group_index = 0
|
|
97
92
|
self.selected_proxy_index = 0
|
|
98
|
-
self.current_group:
|
|
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
|
|
146
|
-
proxies = self.current_group
|
|
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.
|
|
167
|
-
proxies = self.current_group.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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].
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
323
|
-
proxies = data.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|