lingxingapi 2.0.3__tar.gz → 2.1.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.
Files changed (73) hide show
  1. {lingxingapi-2.0.3/src/lingxingapi.egg-info → lingxingapi-2.1.1}/PKG-INFO +3 -1
  2. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/setup.cfg +3 -1
  3. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/__init__.py +4 -1
  4. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/api.py +13 -0
  5. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/base/api.py +13 -2
  6. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/base/schema.py +1 -1
  7. lingxingapi-2.1.1/src/lingxingapi/tunnel.py +582 -0
  8. {lingxingapi-2.0.3 → lingxingapi-2.1.1/src/lingxingapi.egg-info}/PKG-INFO +3 -1
  9. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi.egg-info/SOURCES.txt +1 -0
  10. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi.egg-info/requires.txt +2 -0
  11. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/LICENSE +0 -0
  12. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/MANIFEST.in +0 -0
  13. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/README.md +0 -0
  14. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/pyproject.toml +0 -0
  15. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/ads/__init__.py +0 -0
  16. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/ads/api.py +0 -0
  17. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/ads/param.py +0 -0
  18. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/ads/route.py +0 -0
  19. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/ads/schema.py +0 -0
  20. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/base/__init__.py +0 -0
  21. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/base/param.py +0 -0
  22. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/base/route.py +0 -0
  23. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/basic/__init__.py +0 -0
  24. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/basic/api.py +0 -0
  25. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/basic/param.py +0 -0
  26. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/basic/route.py +0 -0
  27. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/basic/schema.py +0 -0
  28. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/errors.py +0 -0
  29. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fba/__init__.py +0 -0
  30. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fba/api.py +0 -0
  31. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fba/param.py +0 -0
  32. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fba/route.py +0 -0
  33. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fba/schema.py +0 -0
  34. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/fields.py +0 -0
  35. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/finance/__init__.py +0 -0
  36. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/finance/api.py +0 -0
  37. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/finance/param.py +0 -0
  38. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/finance/route.py +0 -0
  39. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/finance/schema.py +0 -0
  40. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/product/__init__.py +0 -0
  41. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/product/api.py +0 -0
  42. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/product/param.py +0 -0
  43. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/product/route.py +0 -0
  44. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/product/schema.py +0 -0
  45. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/purchase/__init__.py +0 -0
  46. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/purchase/api.py +0 -0
  47. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/purchase/param.py +0 -0
  48. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/purchase/route.py +0 -0
  49. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/purchase/schema.py +0 -0
  50. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/sales/__init__.py +0 -0
  51. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/sales/api.py +0 -0
  52. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/sales/param.py +0 -0
  53. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/sales/route.py +0 -0
  54. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/sales/schema.py +0 -0
  55. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/source/__init__.py +0 -0
  56. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/source/api.py +0 -0
  57. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/source/param.py +0 -0
  58. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/source/route.py +0 -0
  59. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/source/schema.py +0 -0
  60. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/tools/__init__.py +0 -0
  61. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/tools/api.py +0 -0
  62. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/tools/param.py +0 -0
  63. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/tools/route.py +0 -0
  64. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/tools/schema.py +0 -0
  65. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/utils.py +0 -0
  66. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/warehourse/__init__.py +0 -0
  67. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/warehourse/api.py +0 -0
  68. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/warehourse/param.py +0 -0
  69. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/warehourse/route.py +0 -0
  70. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi/warehourse/schema.py +0 -0
  71. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi.egg-info/dependency_links.txt +0 -0
  72. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi.egg-info/not-zip-safe +0 -0
  73. {lingxingapi-2.0.3 → lingxingapi-2.1.1}/src/lingxingapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lingxingapi
3
- Version: 2.0.3
3
+ Version: 2.1.1
4
4
  Summary: An async API client for LingXing (领星) ERP
5
5
  Home-page: https://github.com/AresJef/LingXingApi
6
6
  Author: Jiefu Chen
@@ -15,6 +15,8 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3.8.4
18
+ Requires-Dist: aiohttp_socks>=0.11.0
19
+ Requires-Dist: asyncssh>=2.23.1
18
20
  Requires-Dist: cytimes>=3.1.0
19
21
  Requires-Dist: numpy>=1.25.2
20
22
  Requires-Dist: orjson>=3.10.2
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = lingxingapi
3
- version = 2.0.3
3
+ version = 2.1.1
4
4
  author = Jiefu Chen
5
5
  author_email = keppa1991@163.com
6
6
  description = An async API client for LingXing (领星) ERP
@@ -24,6 +24,8 @@ packages = find:
24
24
  python_requires = >=3.10
25
25
  install_requires =
26
26
  aiohttp>=3.8.4
27
+ aiohttp_socks>=0.11.0
28
+ asyncssh>=2.23.1
27
29
  cytimes>=3.1.0
28
30
  numpy>=1.25.2
29
31
  orjson>=3.10.2
@@ -2,10 +2,13 @@ import logging
2
2
 
3
3
  logging.getLogger(__name__).addHandler(logging.NullHandler())
4
4
 
5
- from lingxingapi.api import API
6
5
  from lingxingapi import errors
6
+ from lingxingapi.api import API
7
+ from lingxingapi.tunnel import AsyncSshSocksTunnel, AsyncSshSocksTunnelConfig
7
8
 
8
9
  __all__ = [
9
10
  "API",
11
+ "AsyncSshSocksTunnel",
12
+ "AsyncSshSocksTunnelConfig",
10
13
  "errors",
11
14
  ]
@@ -1,6 +1,7 @@
1
1
  # -*- coding: utf-8 -*-c
2
2
  from Crypto.Cipher import AES
3
3
  from aiohttp import ClientTimeout
4
+ from aiohttp_socks import ProxyConnector
4
5
  from lingxingapi import errors
5
6
  from lingxingapi.base import schema
6
7
  from lingxingapi.base.api import BaseAPI
@@ -47,6 +48,7 @@ class API(BaseAPI):
47
48
  ignore_internet_connection_wait: int | float = 1,
48
49
  ignore_internet_connection_retry: int = 10,
49
50
  max_tcp_connections: int = 100,
51
+ proxy_connector: ProxyConnector | None = None,
50
52
  ) -> None:
51
53
  """初始化领星 API 客户端
52
54
 
@@ -105,6 +107,8 @@ class API(BaseAPI):
105
107
  默认为 `10`, 仅在 `ignore_internet_connection` 为 `True` 时生效, 若设置为 `-1` 则表示无限重试
106
108
 
107
109
  :param max_tcp_connections `<'int'>`: HTTP 会话的最大 TCP 连接数, 用于控制并发请求的数量, 默认为 100
110
+
111
+ :param proxy_connector `<'ProxyConnector/None'>`: 可选的 SOCKS 代理连接器, 用于通过 SOCKS 代理发送请求, 默认 `None`
108
112
  """
109
113
  # 验证参数
110
114
  # . API 凭证
@@ -195,6 +199,14 @@ class API(BaseAPI):
195
199
  "最大 TCP 连接数必须为非负整数, 而非 %r" % (max_tcp_connections,)
196
200
  )
197
201
  max_tcp_connections: int = max_tcp_connections
202
+ # . SOCKS 代理连接器
203
+ if not proxy_connector is None and not isinstance(
204
+ proxy_connector, ProxyConnector
205
+ ):
206
+ raise errors.ApiSettingsError(
207
+ "SOCKS 代理连接器必须为 ProxyConnector 实例或 None, 而非 %r"
208
+ % (proxy_connector,)
209
+ )
198
210
  # 初始化
199
211
  kwargs = {
200
212
  "app_id": app_id,
@@ -214,6 +226,7 @@ class API(BaseAPI):
214
226
  "ignore_internet_connection_wait": ignore_internet_connection_wait,
215
227
  "ignore_internet_connection_retry": ignore_internet_connection_retry,
216
228
  "max_tcp_connections": max_tcp_connections,
229
+ "proxy_connector": proxy_connector,
217
230
  }
218
231
  super().__init__(**kwargs)
219
232
  self._basic: BasicAPI = BasicAPI(**kwargs)
@@ -4,6 +4,7 @@ from typing import Literal
4
4
  from typing_extensions import Self
5
5
  from orjson import loads as _orjson_loads
6
6
  from Crypto.Cipher._mode_ecb import EcbMode
7
+ from aiohttp_socks import ProxyConnector
7
8
  from aiohttp import TCPConnector, ClientTimeout, ClientSession
8
9
  from lingxingapi import utils, errors
9
10
  from lingxingapi.base import route, schema
@@ -47,6 +48,7 @@ class BaseAPI:
47
48
  ignore_internet_connection_wait: int | float,
48
49
  ignore_internet_connection_retry: int,
49
50
  max_tcp_connections: int,
51
+ proxy_connector: ProxyConnector,
50
52
  ) -> None:
51
53
  """领星 API 基础类, 提供公共方法和属性供子类继承使用
52
54
 
@@ -110,6 +112,8 @@ class BaseAPI:
110
112
  仅在 `ignore_internet_connection` 为 `True` 时生效, 若设置为 `-1` 则表示无限重试
111
113
 
112
114
  :param max_tcp_connections `<'int'>`: HTTP 会话的最大 TCP 连接数, 用于控制并发请求的数量
115
+
116
+ :param proxy_connector `<'ProxyConnector/None'>`: 可选的 SOCKS 代理连接器, 用于通过 SOCKS 代理发送请求
113
117
  """
114
118
  # API 凭证
115
119
  self._app_id: str = app_id
@@ -153,6 +157,9 @@ class BaseAPI:
153
157
  ignore_internet_connection_retry == -1
154
158
  )
155
159
 
160
+ # . SOCKS 代理连接器
161
+ self._proxy_connector: ProxyConnector | None = proxy_connector
162
+
156
163
  async def __aenter__(self) -> Self:
157
164
  """进入 API 客户端异步上下文管理器
158
165
 
@@ -270,11 +277,15 @@ class BaseAPI:
270
277
  while True:
271
278
  # 确保 HTTP 会话可用
272
279
  if BaseAPI._session is None or BaseAPI._session.closed:
280
+ if self._proxy_connector is None:
281
+ connector = TCPConnector(limit=self._max_tcp_connections)
282
+ else:
283
+ connector = self._proxy_connector
273
284
  BaseAPI._session = ClientSession(
274
285
  route.API_SERVER,
275
286
  headers={"Content-Type": "application/json"},
276
287
  timeout=self._timeout,
277
- connector=TCPConnector(limit=self._max_tcp_connections),
288
+ connector=connector,
278
289
  )
279
290
 
280
291
  # 发送请求
@@ -313,7 +324,7 @@ class BaseAPI:
313
324
  )
314
325
  await asyncio.sleep(self._ignore_api_limit_wait)
315
326
  continue
316
-
327
+
317
328
  err = self._add_request_notes(err, params, body, retry_count)
318
329
  raise err
319
330
 
@@ -22,7 +22,7 @@ class TagInfo(BaseModel):
22
22
  """商品的标签信息."""
23
23
 
24
24
  # 领星标签ID (GlobalTag.tag_id) [原字段 'global_tag_id']
25
- tag_id: str = Field(validation_alias="global_tag_id")
25
+ tag_id: int = Field(validation_alias="global_tag_id")
26
26
  # 领星标签名称 (GlobalTag.tag_name) [原字段 'tag_name']
27
27
  tag_name: str = Field(validation_alias="tag_name")
28
28
  # 领星标签颜色 (如: "#FF0000") [原字段 'color']
@@ -0,0 +1,582 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import ipaddress
6
+ import json
7
+ import math
8
+ import re
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+ from urllib.parse import urlparse
12
+
13
+ import aiohttp
14
+ import asyncssh
15
+ from aiohttp_socks import ProxyConnector
16
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
17
+ from typing_extensions import Self
18
+
19
+ _IPV4_PATTERN = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
20
+ _IPV6_PATTERN = re.compile(
21
+ r"(?<![\w:])(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F:.%]*(?![\w:])"
22
+ )
23
+
24
+
25
+ def _validate_timeout(value: float) -> float:
26
+ """校验一次性操作的超时时间。
27
+
28
+ :param value: 超时时间,单位为秒。
29
+ :returns: 转换后的浮点数秒数。
30
+ :raises TypeError: 当超时时间不是整数或浮点数时抛出。
31
+ :raises ValueError: 当超时时间不是有限正数时抛出。
32
+ """
33
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
34
+ raise TypeError(f"timeout must be int or float, not {type(value).__name__}")
35
+ value = float(value)
36
+ if not math.isfinite(value) or value <= 0:
37
+ raise ValueError("timeout must be greater than 0")
38
+ return value
39
+
40
+
41
+ def _normalize_ip_address(value: object) -> str | None:
42
+ """尝试将输入值标准化为合法 IP 地址字符串。"""
43
+ if value is None:
44
+ return None
45
+
46
+ try:
47
+ return str(ipaddress.ip_address(str(value).strip()))
48
+ except ValueError:
49
+ return None
50
+
51
+
52
+ def _extract_ip_address(text: str) -> str:
53
+ """从健康检查响应文本中提取 IP 地址。
54
+
55
+ 支持两类常见响应格式:
56
+ - JSON:例如 `{"ip": "1.2.3.4"}`。
57
+ - 纯文本:例如 `当前 IP:1.2.3.4 来自于:中国 ...`。
58
+
59
+ :param text: 健康检查接口返回的响应文本。
60
+ :returns: 解析出的 IPv4 或 IPv6 地址。
61
+ :raises ValueError: 当响应中无法识别 IP 地址时抛出。
62
+ """
63
+ with contextlib.suppress(json.JSONDecodeError):
64
+ data = json.loads(text)
65
+ if isinstance(data, dict):
66
+ for key in ("ip", "origin", "query", "address"):
67
+ value = data.get(key)
68
+ if isinstance(value, str):
69
+ for item in value.split(","):
70
+ ip = _normalize_ip_address(item)
71
+ if ip is not None:
72
+ return ip
73
+ else:
74
+ ip = _normalize_ip_address(value)
75
+ if ip is not None:
76
+ return ip
77
+
78
+ for pattern in (_IPV4_PATTERN, _IPV6_PATTERN):
79
+ for match in pattern.finditer(text):
80
+ ip = _normalize_ip_address(match.group(0))
81
+ if ip is not None:
82
+ return ip
83
+
84
+ raise ValueError("health check response does not contain an IP address")
85
+
86
+
87
+ def _normalize_url(value: object) -> str:
88
+ """标准化并校验 HTTP(S) URL。"""
89
+ if not isinstance(value, str):
90
+ raise ValueError("health check URLs must be strings")
91
+
92
+ value = value.strip()
93
+ if not value:
94
+ raise ValueError("health check URLs cannot be empty")
95
+
96
+ parsed = urlparse(value)
97
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
98
+ raise ValueError("health check URLs must be absolute HTTP(S) URLs")
99
+
100
+ return value
101
+
102
+
103
+ class AsyncSshSocksTunnelConfig(BaseModel):
104
+ """AsyncSSH SOCKS 隧道配置。
105
+
106
+ 该模型用于集中保存 SSH 服务器、密码或认证文件、本地 SOCKS 监听地址、
107
+ 超时与健康检查相关配置。模型是冻结的,创建后不可修改;字符串字段
108
+ 会自动去除首尾空白,并由 Pydantic 完成基础类型与范围校验。
109
+ """
110
+
111
+ model_config = ConfigDict(
112
+ extra="forbid",
113
+ frozen=True,
114
+ str_strip_whitespace=True,
115
+ )
116
+
117
+ server_host: str = Field(min_length=1, strict=True)
118
+ server_user: str = Field(min_length=1, strict=True)
119
+ server_password: SecretStr | None = Field(default=None, min_length=1, repr=False)
120
+
121
+ server_port: int = Field(default=22, ge=1, le=65535, strict=True)
122
+
123
+ # Use 127.0.0.1 unless you intentionally want other local machines
124
+ # to access this SOCKS proxy.
125
+ local_host: str = Field(default="127.0.0.1", min_length=1, strict=True)
126
+
127
+ # Use 0 if you want the OS to pick an available port automatically.
128
+ local_port: int = Field(default=1080, ge=0, le=65535, strict=True)
129
+
130
+ # Validate outbound traffic before start()/__aenter__ returns.
131
+ validate_on_start: bool = Field(default=True, strict=True)
132
+
133
+ # Example:
134
+ # client_keys=["/home/app/.ssh/id_ed25519"]
135
+ client_keys: tuple[str, ...] | None = None
136
+
137
+ # Production:
138
+ # known_hosts="/home/app/.ssh/known_hosts"
139
+ #
140
+ # Testing only:
141
+ # known_hosts=None
142
+ known_hosts: str | Path | None = None
143
+
144
+ connect_timeout: float = Field(default=15.0, gt=0, allow_inf_nan=False)
145
+ close_timeout: float = Field(default=1.0, gt=0, allow_inf_nan=False)
146
+
147
+ # SSH keepalive. Similar idea to OpenSSH ServerAliveInterval /
148
+ # ServerAliveCountMax.
149
+ keepalive_interval: float = Field(default=30.0, ge=0, allow_inf_nan=False)
150
+ keepalive_count_max: int = Field(default=3, ge=1, strict=True)
151
+
152
+ # Optional health check targets.
153
+ # These should return your tunnel/server public IP.
154
+ health_check_urls: tuple[str, ...] = (
155
+ "http://myip.ipip.net",
156
+ "https://ip.3322.net",
157
+ "https://api.ipify.org?format=json",
158
+ )
159
+
160
+ def __init__(
161
+ self,
162
+ server_host: str | None = None,
163
+ server_user: str | None = None,
164
+ server_password: str | None = None,
165
+ **data: object,
166
+ ) -> None:
167
+ """初始化隧道配置。
168
+
169
+ 支持两种调用方式:
170
+ - `AsyncSshSocksTunnelConfig(server_host="host", server_user="user")`
171
+ - `AsyncSshSocksTunnelConfig("host", "user")`
172
+ - `AsyncSshSocksTunnelConfig("host", "user", "password")`
173
+
174
+ :param server_host: SSH 服务器主机名或 IP 地址。
175
+ :param server_user: SSH 登录用户名。
176
+ :param server_password: SSH 登录密码;若使用密钥认证,可保持为 `None`。
177
+ :param data: 其他配置字段,例如端口、密钥路径和超时时间。
178
+ """
179
+ if server_host is not None:
180
+ if "server_host" in data:
181
+ raise TypeError("server_host was provided twice")
182
+ data["server_host"] = server_host
183
+
184
+ if server_user is not None:
185
+ if "server_user" in data:
186
+ raise TypeError("server_user was provided twice")
187
+ data["server_user"] = server_user
188
+
189
+ if server_password is not None:
190
+ if "server_password" in data:
191
+ raise TypeError("server_password was provided twice")
192
+ data["server_password"] = server_password
193
+
194
+ super().__init__(**data)
195
+
196
+ @field_validator("client_keys", mode="before")
197
+ @classmethod
198
+ def _normalize_client_keys(cls, value: object) -> tuple[str, ...] | None:
199
+ """标准化 SSH 客户端私钥路径。
200
+
201
+ `client_keys` 可以传入单个字符串路径、单个 `Path`,也可以传入
202
+ 多个路径组成的序列。返回值统一为非空字符串元组,便于直接传给
203
+ `asyncssh.connect()`。
204
+ """
205
+ if value is None:
206
+ return None
207
+
208
+ if isinstance(value, (str, Path)):
209
+ keys = (cls._normalize_path_value(value),)
210
+ else:
211
+ try:
212
+ keys = tuple(cls._normalize_path_value(item) for item in value)
213
+ except TypeError as exc:
214
+ raise ValueError(
215
+ "client_keys must be a path or sequence of paths"
216
+ ) from exc
217
+
218
+ if not keys:
219
+ raise ValueError("client_keys cannot be empty")
220
+ return keys
221
+
222
+ @field_validator("known_hosts", mode="before")
223
+ @classmethod
224
+ def _normalize_known_hosts(cls, value: object) -> str | None:
225
+ """标准化 known_hosts 文件路径。
226
+
227
+ 允许传入字符串或 `Path`;如果传入 `None`,表示不使用 known_hosts
228
+ 文件校验,通常只应在测试环境中使用。
229
+ """
230
+ if value is None:
231
+ return None
232
+ return cls._normalize_path_value(value)
233
+
234
+ @field_validator("health_check_urls", mode="before")
235
+ @classmethod
236
+ def _normalize_health_check_urls(cls, value: object) -> tuple[str, ...]:
237
+ """标准化健康检查地址列表。
238
+
239
+ 健康检查地址可以传入单个字符串,也可以传入字符串序列。地址会按
240
+ 顺序尝试,第一个成功返回可解析 IP 的地址会作为结果来源。
241
+ """
242
+ if isinstance(value, str):
243
+ urls = (_normalize_url(value),)
244
+ else:
245
+ try:
246
+ urls = tuple(_normalize_url(url) for url in value)
247
+ except TypeError as exc:
248
+ raise ValueError(
249
+ "health_check_urls must be a URL or sequence of URLs"
250
+ ) from exc
251
+
252
+ if not urls:
253
+ raise ValueError("health_check_urls cannot be empty")
254
+ return urls
255
+
256
+ @staticmethod
257
+ def _normalize_path_value(value: object) -> str:
258
+ """将路径值转换为非空字符串。
259
+
260
+ :param value: 字符串路径或 `pathlib.Path` 对象。
261
+ :returns: 去除首尾空白后的字符串路径。
262
+ :raises ValueError: 当路径类型不支持或路径为空时抛出。
263
+ """
264
+ if not isinstance(value, (str, Path)):
265
+ raise ValueError("path values must be str or Path")
266
+ value = str(value).strip()
267
+ if not value:
268
+ raise ValueError("path values cannot be empty")
269
+ return value
270
+
271
+
272
+ class AsyncSshSocksTunnel:
273
+ """基于 AsyncSSH 的异步 SOCKS 代理隧道。
274
+
275
+ 该类负责连接远程 SSH 服务器,并在本机启动一个 SOCKS5 代理监听端口。
276
+ 默认会在启动时验证 SOCKS 隧道能否访问外网;如果只想验证 SSH 连接
277
+ 和本地端口绑定,可在配置中设置 `validate_on_start=False`。
278
+ 可以通过 `async with` 自动启动和关闭隧道,也可以手动调用 `start()`
279
+ 与 `stop()` 控制生命周期。
280
+ """
281
+
282
+ def __init__(self, config: AsyncSshSocksTunnelConfig) -> None:
283
+ """初始化隧道实例。
284
+
285
+ 初始化不会立即建立 SSH 连接;连接会在调用 `start()` 或进入异步
286
+ 上下文管理器时创建。
287
+
288
+ :param config: 已通过 Pydantic 校验的隧道配置。
289
+ """
290
+ self.config = config
291
+
292
+ self.conn: asyncssh.SSHClientConnection | None = None
293
+ self.listener: asyncssh.SSHListener | None = None
294
+
295
+ self._local_port: int | None = None
296
+ self._lock = asyncio.Lock()
297
+
298
+ async def __aenter__(self) -> Self:
299
+ """进入异步上下文并启动隧道。
300
+
301
+ :returns: 已启动的隧道实例。
302
+ """
303
+ await self.start()
304
+ return self
305
+
306
+ async def __aexit__(
307
+ self,
308
+ exc_type: type[BaseException] | None,
309
+ exc: BaseException | None,
310
+ tb: TracebackType | None,
311
+ ) -> None:
312
+ """退出异步上下文并关闭隧道。
313
+
314
+ 无论上下文内部是否发生异常,都会尝试关闭 SOCKS 监听器和 SSH 连接。
315
+ """
316
+ await self.stop()
317
+
318
+ @property
319
+ def is_started(self) -> bool:
320
+ """判断隧道是否处于已启动且 SSH 连接未关闭的状态。"""
321
+ return (
322
+ self.conn is not None
323
+ and self.listener is not None
324
+ and not self.conn.is_closed()
325
+ )
326
+
327
+ @property
328
+ def local_host(self) -> str:
329
+ """返回本地 SOCKS 代理监听地址。"""
330
+ return self.config.local_host
331
+
332
+ @property
333
+ def local_port(self) -> int:
334
+ """返回本地 SOCKS 代理实际监听端口。
335
+
336
+ 当配置 `local_port=0` 时,端口由操作系统自动分配;该属性会在
337
+ 隧道启动后返回最终绑定的端口。
338
+
339
+ :raises RuntimeError: 当隧道尚未启动时抛出。
340
+ """
341
+ if self._local_port is None:
342
+ raise RuntimeError("SSH SOCKS tunnel has not been started")
343
+ return self._local_port
344
+
345
+ @property
346
+ def proxy_url(self) -> str:
347
+ """返回可用于 aiohttp-socks 的 SOCKS5 代理 URL。
348
+
349
+ `aiohttp_socks.ProxyConnector` 支持 `socks5://`,不支持 curl 风格的
350
+ `socks5h://`。SOCKS5 仍可以转发域名请求,是否远端解析由连接器的
351
+ `rdns` 参数控制。
352
+ """
353
+ return f"socks5://{self.local_host}:{self.local_port}"
354
+
355
+ async def start(self) -> None:
356
+ """启动 SSH SOCKS 隧道。
357
+
358
+ 方法是幂等的:如果隧道已经启动,则直接返回。若上一次启动留下了
359
+ 半初始化资源,会先清理旧资源,再重新建立 SSH 连接和 SOCKS 监听器。
360
+ 当 `validate_on_start=True` 时,还会在返回前验证出站代理可用性。
361
+ 启动过程中发生异常时,会关闭已经创建的资源后再继续抛出异常。
362
+ """
363
+ async with self._lock:
364
+ if self.is_started:
365
+ return
366
+
367
+ if self.conn is not None or self.listener is not None:
368
+ await self._close_resources(
369
+ self.listener,
370
+ self.conn,
371
+ self.config.close_timeout,
372
+ )
373
+ self.listener = None
374
+ self.conn = None
375
+ self._local_port = None
376
+
377
+ conn: asyncssh.SSHClientConnection | None = None
378
+ listener: asyncssh.SSHListener | None = None
379
+
380
+ try:
381
+ conn = await asyncssh.connect(
382
+ self.config.server_host,
383
+ port=self.config.server_port,
384
+ username=self.config.server_user,
385
+ password=(
386
+ self.config.server_password.get_secret_value()
387
+ if self.config.server_password is not None
388
+ else None
389
+ ),
390
+ client_keys=self.config.client_keys,
391
+ known_hosts=self.config.known_hosts,
392
+ login_timeout=self.config.connect_timeout,
393
+ keepalive_interval=self.config.keepalive_interval,
394
+ keepalive_count_max=self.config.keepalive_count_max,
395
+ )
396
+
397
+ listener = await conn.forward_socks(
398
+ self.config.local_host,
399
+ self.config.local_port,
400
+ )
401
+
402
+ self.conn = conn
403
+ self.listener = listener
404
+ self._local_port = listener.get_port()
405
+
406
+ if self.config.validate_on_start:
407
+ await self.check_outbound_ip()
408
+
409
+ except BaseException:
410
+ await self._close_resources(
411
+ listener,
412
+ conn,
413
+ self.config.close_timeout,
414
+ )
415
+ self.conn = None
416
+ self.listener = None
417
+ self._local_port = None
418
+ raise
419
+
420
+ async def stop(self) -> None:
421
+ """关闭 SSH SOCKS 隧道。
422
+
423
+ 方法是幂等的:即使隧道未启动,也可以安全调用。关闭时会先清空实例
424
+ 状态,再等待监听器和 SSH 连接关闭,避免并发调用看到过期状态。
425
+ """
426
+ async with self._lock:
427
+ listener = self.listener
428
+ conn = self.conn
429
+
430
+ self.listener = None
431
+ self.conn = None
432
+ self._local_port = None
433
+
434
+ await self._close_resources(listener, conn, self.config.close_timeout)
435
+
436
+ @staticmethod
437
+ async def _close_resources(
438
+ listener: asyncssh.SSHListener | None,
439
+ conn: asyncssh.SSHClientConnection | None,
440
+ timeout: float,
441
+ ) -> None:
442
+ """关闭 AsyncSSH 监听器和连接。
443
+
444
+ 该方法会先停止本地监听,再尝试优雅关闭 SSH 连接。如果 SSH 连接
445
+ 未能在指定时间内关闭,会调用 `abort()` 强制终止。监听器关闭等待
446
+ 放在 SSH 连接处理之后,并同样设置上限,避免退出异步上下文后进程
447
+ 仍被后台连接挂住。
448
+
449
+ :param listener: 可选的本地 SOCKS 监听器。
450
+ :param conn: 可选的 SSH 客户端连接。
451
+ :param timeout: 等待资源关闭的最大时间,单位为秒。
452
+ """
453
+ if listener is not None:
454
+ listener.close()
455
+
456
+ if conn is not None:
457
+ conn.close()
458
+
459
+ if conn is not None:
460
+ try:
461
+ await asyncio.wait_for(conn.wait_closed(), timeout=timeout)
462
+ except Exception:
463
+ conn.abort()
464
+ with contextlib.suppress(Exception):
465
+ await asyncio.wait_for(conn.wait_closed(), timeout=1.0)
466
+
467
+ if listener is not None:
468
+ with contextlib.suppress(Exception):
469
+ await asyncio.wait_for(listener.wait_closed(), timeout=timeout)
470
+
471
+ async def check_local_port(self, timeout: float = 5.0) -> None:
472
+ """检查本地 SOCKS 监听端口是否可连接。
473
+
474
+ 该方法只验证本机端口是否能建立 TCP 连接,不验证代理转发是否可用。
475
+
476
+ :param timeout: 最大等待时间,单位为秒。
477
+ :raises RuntimeError: 当隧道尚未启动时抛出。
478
+ :raises TimeoutError: 当连接检查超时时抛出。
479
+ :raises OSError: 当本地端口无法连接时抛出。
480
+ """
481
+ await asyncio.wait_for(
482
+ self._check_local_port_once(),
483
+ timeout=_validate_timeout(timeout),
484
+ )
485
+
486
+ async def _check_local_port_once(self) -> None:
487
+ """执行一次本地监听端口 TCP 连接检查。"""
488
+ _, writer = await asyncio.open_connection(
489
+ self.local_host,
490
+ self.local_port,
491
+ )
492
+ writer.close()
493
+ with contextlib.suppress(Exception):
494
+ await writer.wait_closed()
495
+
496
+ async def check_outbound_ip(self, timeout: float = 5.0) -> str:
497
+ """通过 SOCKS 隧道请求健康检查地址并返回出口 IP。
498
+
499
+ 默认按顺序请求 `health_check_urls` 中的地址。该方法同时支持
500
+ `{"ip": "1.2.3.4"}` 这类 JSON 响应,以及包含 IP 地址的纯文本
501
+ 响应,可用于确认请求确实经由 SSH 隧道转发。
502
+
503
+ :param timeout: 整个健康检查流程的总超时时间,单位为秒。
504
+ :returns: 健康检查服务返回的出口 IP 字符串。
505
+ :raises RuntimeError: 当隧道尚未启动时抛出。
506
+ :raises ValueError: 当健康检查响应中没有 `ip` 字段时抛出。
507
+ :raises aiohttp.ClientError: 当 HTTP 请求失败时抛出。
508
+ """
509
+ timeout = _validate_timeout(timeout)
510
+ return await asyncio.wait_for(
511
+ self._check_outbound_ip_with_fallback(timeout),
512
+ timeout=timeout,
513
+ )
514
+
515
+ async def _check_outbound_ip_with_fallback(self, timeout: float) -> str:
516
+ """按顺序尝试所有健康检查地址并返回第一个成功解析的出口 IP。
517
+
518
+ :param timeout: 整个健康检查流程的总超时时间,单位为秒。
519
+ :returns: 解析出的出口 IP。
520
+ :raises RuntimeError: 当所有健康检查地址都失败时抛出。
521
+ """
522
+ urls = self._health_check_urls()
523
+ deadline = asyncio.get_running_loop().time() + timeout
524
+ last_exc: BaseException | None = None
525
+
526
+ for index, url in enumerate(urls):
527
+ remaining = deadline - asyncio.get_running_loop().time()
528
+ if remaining <= 0:
529
+ break
530
+
531
+ per_url_timeout = remaining / (len(urls) - index)
532
+ try:
533
+ return await asyncio.wait_for(
534
+ self._fetch_outbound_ip(url, per_url_timeout),
535
+ timeout=per_url_timeout,
536
+ )
537
+ except Exception as exc:
538
+ last_exc = exc
539
+
540
+ raise RuntimeError("all outbound IP health check URLs failed") from last_exc
541
+
542
+ def _health_check_urls(self) -> tuple[str, ...]:
543
+ """返回去重后的健康检查地址列表。
544
+
545
+ :returns: 按优先级排序的健康检查地址元组。
546
+ """
547
+ urls: list[str] = []
548
+ for url in self.config.health_check_urls:
549
+ if url not in urls:
550
+ urls.append(url)
551
+ return tuple(urls)
552
+
553
+ async def _fetch_outbound_ip(self, url: str, timeout: float) -> str:
554
+ """请求单个健康检查地址并解析出口 IP。
555
+
556
+ :param url: 健康检查地址。
557
+ :param timeout: 单个请求的超时时间,单位为秒。
558
+ :returns: 解析出的出口 IP。
559
+ """
560
+ timeout_config = aiohttp.ClientTimeout(total=timeout)
561
+ connector = ProxyConnector.from_url(self.proxy_url, rdns=True)
562
+
563
+ async with aiohttp.ClientSession(
564
+ connector=connector,
565
+ timeout=timeout_config,
566
+ ) as session:
567
+ async with session.get(url) as resp:
568
+ resp.raise_for_status()
569
+ text = await resp.text()
570
+
571
+ return _extract_ip_address(text)
572
+
573
+ def create_connector(self) -> ProxyConnector:
574
+ """创建绑定当前隧道代理地址的 aiohttp-socks 连接器。
575
+
576
+ 调用方负责将连接器交给 `aiohttp.ClientSession` 使用,并按 aiohttp
577
+ 的资源管理规则关闭会话。
578
+
579
+ :returns: 指向当前 SOCKS 代理的 `ProxyConnector`。
580
+ :raises RuntimeError: 当隧道尚未启动时抛出。
581
+ """
582
+ return ProxyConnector.from_url(self.proxy_url, rdns=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lingxingapi
3
- Version: 2.0.3
3
+ Version: 2.1.1
4
4
  Summary: An async API client for LingXing (领星) ERP
5
5
  Home-page: https://github.com/AresJef/LingXingApi
6
6
  Author: Jiefu Chen
@@ -15,6 +15,8 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3.8.4
18
+ Requires-Dist: aiohttp_socks>=0.11.0
19
+ Requires-Dist: asyncssh>=2.23.1
18
20
  Requires-Dist: cytimes>=3.1.0
19
21
  Requires-Dist: numpy>=1.25.2
20
22
  Requires-Dist: orjson>=3.10.2
@@ -7,6 +7,7 @@ src/lingxingapi/__init__.py
7
7
  src/lingxingapi/api.py
8
8
  src/lingxingapi/errors.py
9
9
  src/lingxingapi/fields.py
10
+ src/lingxingapi/tunnel.py
10
11
  src/lingxingapi/utils.py
11
12
  src/lingxingapi.egg-info/PKG-INFO
12
13
  src/lingxingapi.egg-info/SOURCES.txt
@@ -1,4 +1,6 @@
1
1
  aiohttp>=3.8.4
2
+ aiohttp_socks>=0.11.0
3
+ asyncssh>=2.23.1
2
4
  cytimes>=3.1.0
3
5
  numpy>=1.25.2
4
6
  orjson>=3.10.2
File without changes
File without changes
File without changes
File without changes