sing-box-cli 0.0.18__tar.gz → 0.0.21__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.18 → sing_box_cli-0.0.21}/CHANGELOG.md +29 -0
  2. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/PKG-INFO +3 -2
  3. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/pyproject.toml +3 -3
  4. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/src/sing_box_service/__init__.py +1 -1
  5. sing_box_cli-0.0.21/src/sing_box_service/client.py +245 -0
  6. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/src/sing_box_service/config.py +45 -4
  7. sing_box_cli-0.0.21/src/sing_box_service/connections.py +449 -0
  8. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/src/sing_box_service/main.py +105 -3
  9. sing_box_cli-0.0.21/src/sing_box_service/monitor.py +523 -0
  10. sing_box_cli-0.0.21/src/sing_box_service/policy.py +424 -0
  11. sing_box_cli-0.0.21/src/sing_box_service/run.py +24 -0
  12. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/src/sing_box_service/service.py +3 -3
  13. sing_box_cli-0.0.21/src/sing_box_service/widget.py +28 -0
  14. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/uv.lock +4 -97
  15. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/.github/dependabot.yml +0 -0
  16. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/.github/workflows/main.yml +0 -0
  17. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/.gitignore +0 -0
  18. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/.pre-commit-config.yaml +0 -0
  19. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/LICENSE +0 -0
  20. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/README.md +0 -0
  21. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/assets/image.png +0 -0
  22. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/scripts/bump.sh +0 -0
  23. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/scripts/format.sh +0 -0
  24. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/scripts/lint.sh +0 -0
  25. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/scripts/pre-commit.sh +0 -0
  26. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/src/sing_box_service/py.typed +0 -0
  27. {sing_box_cli-0.0.18 → sing_box_cli-0.0.21}/tests/test_config.py +0 -0
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.0.21 - 2025-03-18
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Replace None of host with destination ip
10
+
11
+ ## 0.0.20 - 2025-03-18
12
+
13
+ ### 🚀 Features
14
+
15
+ - Add stats command to visualize traffic, memory and connections
16
+ - Add cnns command to manage connections
17
+ - Add proxy command to select policy
18
+
19
+ ### 🚜 Refactor
20
+
21
+ - Replace requests with httpx
22
+ - Check url using httpx
23
+
24
+ ## 0.0.19 - 2025-03-15
25
+
26
+ ### 🚀 Features
27
+
28
+ - Add simple run command
29
+
30
+ ### ⚙️ Miscellaneous Tasks
31
+
32
+ - Add type annotationfor __init__
33
+
5
34
  ## 0.0.18 - 2025-03-15
6
35
 
7
36
  ### ⚙️ Miscellaneous Tasks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sing-box-cli
3
- Version: 0.0.18
3
+ Version: 0.0.21
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
@@ -38,8 +38,9 @@ Classifier: Programming Language :: Python :: 3.11
38
38
  Classifier: Programming Language :: Python :: 3.12
39
39
  Classifier: Programming Language :: Python :: 3.13
40
40
  Requires-Python: >=3.10
41
+ Requires-Dist: httpx>=0.28.1
42
+ Requires-Dist: prompt-toolkit>=3.0.50
41
43
  Requires-Dist: psutil>=6.1.1
42
- Requires-Dist: requests>=2.32.3
43
44
  Requires-Dist: typer>=0.15.1
44
45
  Description-Content-Type: text/markdown
45
46
 
@@ -24,8 +24,9 @@ maintainers = [{ name = "A.J.Zeller", email = "zhouge1831@gmail.com" }]
24
24
 
25
25
  dynamic = ["version"] # https://hatch.pypa.io/latest/config/metadata/#version
26
26
  dependencies = [
27
+ "httpx>=0.28.1",
28
+ "prompt-toolkit>=3.0.50",
27
29
  "psutil>=6.1.1",
28
- "requests>=2.32.3",
29
30
  "typer>=0.15.1",
30
31
  ]
31
32
 
@@ -57,7 +58,6 @@ dev = [
57
58
  "bump-my-version>=0.28.0",
58
59
  "typos>=1.26.8",
59
60
  "types-psutil>=6.1.0.20241221",
60
- "types-requests>=2.32.0.20241016",
61
61
  "pytest>=8.3.5",
62
62
  ]
63
63
 
@@ -210,7 +210,7 @@ sort_commits = "oldest"
210
210
 
211
211
 
212
212
  [tool.bumpversion]
213
- current_version = "0.0.18"
213
+ current_version = "0.0.21"
214
214
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
215
215
  serialize = ["{major}.{minor}.{patch}"]
216
216
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.0.18"
5
+ __version__ = "0.0.21"
@@ -0,0 +1,245 @@
1
+ """
2
+ Sing-Box API Client module.
3
+ Provides an async client for interacting with Sing-Box HTTP API.
4
+ """
5
+
6
+ import json
7
+ from collections.abc import AsyncGenerator
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+
13
+ class SingBoxAPIClient:
14
+ """Async client for interacting with Sing-Box API.
15
+
16
+ Args:
17
+ base_url: The base URL of the Sing-Box API
18
+ token: The API authentication token
19
+ timeout: Request timeout in seconds
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: str = "http://127.0.0.1:9090",
25
+ token: str | None = None,
26
+ timeout: float = 10.0,
27
+ ) -> None:
28
+ self.base_url = base_url
29
+ self.headers = {"Authorization": f"Bearer {token}"} if token else {}
30
+ self.timeout = timeout
31
+ if not self._health_check():
32
+ raise ValueError(f"Invalid to initialize client: {self.base_url}")
33
+
34
+ def _health_check(self) -> bool:
35
+ """Check if the API is reachable.
36
+
37
+ Returns:
38
+ True if the API is reachable, False otherwise
39
+ """
40
+ try:
41
+ response = httpx.get(
42
+ f"{self.base_url}", timeout=self.timeout, headers=self.headers
43
+ )
44
+ return response.status_code == 200
45
+ except httpx.HTTPError:
46
+ return False
47
+
48
+ async def _make_request(
49
+ self,
50
+ method: str,
51
+ endpoint: str,
52
+ params: dict[str, Any] | None = None,
53
+ data: dict[str, Any] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """
56
+ Make a request to the API.
57
+
58
+ Args:
59
+ method: HTTP method (GET, POST, etc.)
60
+ endpoint: API endpoint
61
+ params: URL parameters
62
+ data: Request body data
63
+
64
+ Returns:
65
+ JSON response as dictionary
66
+
67
+ Raises:
68
+ httpx.HTTPError: If the request fails
69
+ """
70
+ url = f"{self.base_url}{endpoint}"
71
+ async with httpx.AsyncClient() as client:
72
+ response = await client.request(
73
+ method=method,
74
+ url=url,
75
+ params=params,
76
+ json=data,
77
+ headers=self.headers,
78
+ timeout=self.timeout,
79
+ )
80
+ response.raise_for_status()
81
+ if response.content:
82
+ return dict(response.json())
83
+ return {}
84
+
85
+ async def traffic_stream(self) -> AsyncGenerator[dict[str, Any], None]:
86
+ """
87
+ Get traffic statistics as a stream of updates.
88
+
89
+ Yields:
90
+ Dictionary containing traffic data (up/down in B/s)
91
+ """
92
+ url = f"{self.base_url}/traffic"
93
+
94
+ async with httpx.AsyncClient() as client:
95
+ async with client.stream(
96
+ "GET", url, headers=self.headers, timeout=None
97
+ ) as response:
98
+ response.raise_for_status()
99
+ async for line in response.aiter_lines():
100
+ if line.strip(): # Skip empty lines
101
+ try:
102
+ yield json.loads(line)
103
+ except json.JSONDecodeError:
104
+ # Skip invalid JSON lines
105
+ continue
106
+
107
+ async def memory_stream(self) -> AsyncGenerator[dict[str, Any], None]:
108
+ """
109
+ Get memory statistics as a stream of updates.
110
+
111
+ Yields:
112
+ Dictionary containing memory data (inuse/total in bytes)
113
+ """
114
+ url = f"{self.base_url}/memory"
115
+
116
+ async with httpx.AsyncClient() as client:
117
+ async with client.stream(
118
+ "GET", url, headers=self.headers, timeout=None
119
+ ) as response:
120
+ response.raise_for_status()
121
+ async for line in response.aiter_lines():
122
+ if line.strip(): # Skip empty lines
123
+ try:
124
+ yield json.loads(line)
125
+ except json.JSONDecodeError:
126
+ # Skip invalid JSON lines
127
+ continue
128
+
129
+ async def get_connections(self) -> dict[str, Any]:
130
+ """
131
+ Get current connections.
132
+
133
+ Returns:
134
+ Dictionary containing active connections
135
+ """
136
+ return await self._make_request("GET", "/connections")
137
+
138
+ async def close_connection(self, connection_id: str) -> dict[str, Any]:
139
+ """
140
+ Close a specific connection.
141
+
142
+ Args:
143
+ connection_id: ID of the connection to close
144
+
145
+ Returns:
146
+ Response from the API
147
+ """
148
+ return await self._make_request("DELETE", f"/connections/{connection_id}")
149
+
150
+ async def close_all_connections(self) -> dict[str, Any]:
151
+ """
152
+ Close all connections.
153
+
154
+ Returns:
155
+ Response from the API
156
+ """
157
+ return await self._make_request("DELETE", "/connections")
158
+
159
+ async def get_groups(self) -> dict[str, Any]:
160
+ """
161
+ Get all policy groups.
162
+
163
+ Returns:
164
+ Dictionary containing policy groups information
165
+ """
166
+ return await self._make_request("GET", "/group")
167
+
168
+ async def get_group(self, group_name: str) -> dict[str, Any]:
169
+ """
170
+ Get information about a specific policy group.
171
+
172
+ Args:
173
+ group_name: Name of the policy group
174
+
175
+ Returns:
176
+ Dictionary containing the policy group information
177
+ """
178
+ return await self._make_request("GET", f"/group/{group_name}")
179
+
180
+ async def test_group_delay(
181
+ self,
182
+ group_name: str,
183
+ url: str = "https://cp.cloudflare.com/generate_204",
184
+ timeout: int = 5000,
185
+ ) -> dict[str, Any]:
186
+ """
187
+ Test delay for all proxies in a policy group.
188
+
189
+ Args:
190
+ group_name: Name of the policy group
191
+ url: URL to test latency against
192
+ timeout: Timeout in milliseconds
193
+
194
+ Returns:
195
+ Dictionary containing delay test results
196
+ """
197
+ params = {"url": url, "timeout": timeout}
198
+ return await self._make_request(
199
+ "GET", f"/group/{group_name}/delay", params=params
200
+ )
201
+
202
+ async def test_proxy_delay(
203
+ self,
204
+ proxy_name: str,
205
+ url: str = "https://cp.cloudflare.com/generate_204",
206
+ timeout: int = 5000,
207
+ ) -> dict[str, Any]:
208
+ """
209
+ Test delay for a specific proxy.
210
+
211
+ Args:
212
+ proxy_name: Name of the proxy
213
+ url: URL to test latency against
214
+ timeout: Timeout in milliseconds
215
+
216
+ Returns:
217
+ Dictionary containing delay test results
218
+ """
219
+ params = {"url": url, "timeout": timeout}
220
+ return await self._make_request(
221
+ "GET", f"/proxies/{proxy_name}/delay", params=params
222
+ )
223
+
224
+ async def select_proxy(self, proxy_name: str, selected: str) -> dict[str, Any]:
225
+ """
226
+ Select a proxy for a selector proxy group.
227
+
228
+ Args:
229
+ proxy_name: Name of the proxy selector
230
+ selected: Name of the proxy to select
231
+
232
+ Returns:
233
+ Response from the API
234
+ """
235
+ data = {"name": selected}
236
+ return await self._make_request("PUT", f"/proxies/{proxy_name}", data=data)
237
+
238
+ async def get_version(self) -> dict[str, Any]:
239
+ """
240
+ Get Sing-Box version.
241
+
242
+ Returns:
243
+ Dictionary containing version information
244
+ """
245
+ return await self._make_request("GET", "/version")
@@ -1,9 +1,11 @@
1
+ import json
1
2
  import os
2
3
  import platform
3
4
  import shutil
4
5
  from pathlib import Path
6
+ from typing import Any
5
7
 
6
- import requests
8
+ import httpx
7
9
  from rich import print
8
10
 
9
11
 
@@ -64,6 +66,28 @@ class Config:
64
66
  return ""
65
67
  return self.subscription_file.read_text().strip()
66
68
 
69
+ @property
70
+ def api_base_url(self) -> str:
71
+ config = load_json_config(self.config_file)
72
+ url = (
73
+ config.get("experimental", {})
74
+ .get("clash_api", {})
75
+ .get("external_controller", "")
76
+ )
77
+ if isinstance(url, str) and url:
78
+ if not url.startswith("http"):
79
+ url = f"http://{url}"
80
+ return url
81
+ return ""
82
+
83
+ @property
84
+ def api_secret(self) -> str:
85
+ config = load_json_config(self.config_file)
86
+ token = config.get("experimental", {}).get("clash_api", {}).get("secret", "")
87
+ if isinstance(token, str) and token:
88
+ return token
89
+ return ""
90
+
67
91
  def update_config(self) -> bool:
68
92
  """download configuration from subscription URL and show differences"""
69
93
  if not self.sub_url:
@@ -77,7 +101,7 @@ class Config:
77
101
  )
78
102
  print(f"⌛ Updating configuration from {self.sub_url}")
79
103
  try:
80
- response = requests.get(self.sub_url)
104
+ response = httpx.get(self.sub_url)
81
105
  response.raise_for_status()
82
106
  new_config = response.text
83
107
  self.config_file.write_text(new_config, encoding="utf-8")
@@ -95,8 +119,7 @@ class Config:
95
119
  return False
96
120
 
97
121
  def add_subscription(self, url: str) -> bool:
98
- if not url.startswith(("http://", "https://")):
99
- print("❌ Invalid URL format.")
122
+ if not check_url(url):
100
123
  return False
101
124
  self.subscription_file.write_text(url.strip())
102
125
  print("📁 Subscription added successfully.")
@@ -154,3 +177,21 @@ def show_diff_config(current_config: str, new_config: str) -> None:
154
177
  print(f"[dim]{line}[/dim]")
155
178
  else:
156
179
  print(f"[white]{line}[/white]")
180
+
181
+
182
+ def check_url(url: str) -> bool:
183
+ try:
184
+ response = httpx.head(url)
185
+ response.raise_for_status()
186
+ return True
187
+ except Exception as e:
188
+ print(f"❌ Invalid URL: {e}")
189
+ return False
190
+
191
+
192
+ def load_json_config(config_file: Path) -> dict[str, Any]:
193
+ try:
194
+ return dict(json.loads(config_file.read_text(encoding="utf-8")))
195
+ except Exception as e:
196
+ print(f"❌ Failed to load configuration: {e}")
197
+ return {}