asockslib 0.1.0__py3-none-any.whl

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.
asockslib/__init__.py ADDED
@@ -0,0 +1,117 @@
1
+ """ASocksLib — Python library and CLI for the ASocks Proxy API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from asockslib.benchmark import (
6
+ CountryPingResult,
7
+ FindBestResult,
8
+ ProxyBenchmarkResult,
9
+ benchmark_proxies,
10
+ find_best_proxies,
11
+ ping_proxy,
12
+ select_best_proxies,
13
+ )
14
+ from asockslib.client import ASocksClient
15
+ from asockslib.exceptions import (
16
+ APIConnectionError,
17
+ ASocksError,
18
+ AuthenticationError,
19
+ InsufficientBalanceError,
20
+ NoAvailableProxyError,
21
+ PortNotFoundError,
22
+ ProxyHealthError,
23
+ RateLimitError,
24
+ )
25
+ from asockslib.geo_picker import (
26
+ GeoPicker,
27
+ PickedASN,
28
+ PickedCity,
29
+ PickedConnectionType,
30
+ PickedCountry,
31
+ PickedProxyType,
32
+ PickedServerPortType,
33
+ PickedState,
34
+ )
35
+ from asockslib.models import (
36
+ ASNInfo,
37
+ ASNListResponse,
38
+ AuthType,
39
+ BalanceResponse,
40
+ CityInfo,
41
+ ConnectionType,
42
+ ConnectionTypeId,
43
+ CountryInfo,
44
+ CreatePortRequest,
45
+ CreateTemplateRequest,
46
+ PortFilterParams,
47
+ PortInfo,
48
+ PortListResponse,
49
+ PortStatus,
50
+ ProxyType,
51
+ ProxyTypeId,
52
+ ServerPortType,
53
+ StateInfo,
54
+ UpdatePortRequest,
55
+ UpdateTemplateRequest,
56
+ WhitelistAddRequest,
57
+ )
58
+ from asockslib.proxy_pool import PoolStrategy, ProxyPool, ProxyPoolStats, ProxyStore
59
+ from asockslib.quick import get_proxies, get_proxies_sync
60
+ from asockslib.smart_proxy import SmartProxy
61
+
62
+ __all__ = [
63
+ "APIConnectionError",
64
+ "ASNInfo",
65
+ "ASNListResponse",
66
+ "ASocksClient",
67
+ "ASocksError",
68
+ "AuthType",
69
+ "AuthenticationError",
70
+ "BalanceResponse",
71
+ "CityInfo",
72
+ "ConnectionType",
73
+ "ConnectionTypeId",
74
+ "CountryInfo",
75
+ "CountryPingResult",
76
+ "CreatePortRequest",
77
+ "CreateTemplateRequest",
78
+ "FindBestResult",
79
+ "GeoPicker",
80
+ "InsufficientBalanceError",
81
+ "NoAvailableProxyError",
82
+ "PickedASN",
83
+ "PickedCity",
84
+ "PickedConnectionType",
85
+ "PickedCountry",
86
+ "PickedProxyType",
87
+ "PickedServerPortType",
88
+ "PickedState",
89
+ "PoolStrategy",
90
+ "PortFilterParams",
91
+ "PortInfo",
92
+ "PortListResponse",
93
+ "PortNotFoundError",
94
+ "PortStatus",
95
+ "ProxyBenchmarkResult",
96
+ "ProxyHealthError",
97
+ "ProxyPool",
98
+ "ProxyPoolStats",
99
+ "ProxyStore",
100
+ "ProxyType",
101
+ "ProxyTypeId",
102
+ "RateLimitError",
103
+ "ServerPortType",
104
+ "SmartProxy",
105
+ "StateInfo",
106
+ "UpdatePortRequest",
107
+ "UpdateTemplateRequest",
108
+ "WhitelistAddRequest",
109
+ "benchmark_proxies",
110
+ "find_best_proxies",
111
+ "get_proxies",
112
+ "get_proxies_sync",
113
+ "ping_proxy",
114
+ "select_best_proxies",
115
+ ]
116
+
117
+ __version__ = "0.1.0"
asockslib/_console.py ADDED
@@ -0,0 +1,18 @@
1
+ """Centralized Rich Console singleton.
2
+
3
+ Every module that needs to print to the terminal should import
4
+ ``console`` from here instead of creating its own instance.
5
+
6
+ Usage::
7
+
8
+ from asockslib._console import console
9
+
10
+ console.print("[green]OK[/green]")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from rich.console import Console
16
+
17
+ console: Console = Console()
18
+ """Module-level singleton :class:`~rich.console.Console` instance."""
@@ -0,0 +1,132 @@
1
+ """Shared port management mixin for SmartProxy and ProxyPool.
2
+
3
+ Extracts the duplicated ``_fetch_matching_ports`` and ``_create_ports``
4
+ methods into a reusable mixin, eliminating code duplication.
5
+
6
+ Both :class:`SmartProxy` and :class:`ProxyPool` store identical sets of
7
+ proxy-creation parameters and perform the same operations — this mixin
8
+ encapsulates that shared behaviour.
9
+ """
10
+
11
+ # This mixin's entire purpose is to read the host class's private
12
+ # (`_`-prefixed) configuration attributes — that's package-internal sharing
13
+ # by design, not an encapsulation leak, so reportPrivateUsage is silenced
14
+ # for this file only.
15
+ # pyright: reportPrivateUsage=false
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
21
+
22
+ from asockslib.models import (
23
+ CreatePortRequest,
24
+ PortFilterParams,
25
+ PortInfo,
26
+ PortStatus,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from asockslib.client import ASocksClient
31
+
32
+ logger = logging.getLogger("asockslib")
33
+
34
+
35
+ @runtime_checkable
36
+ class _HasClient(Protocol):
37
+ """Protocol for classes that hold an ASocksClient and proxy params."""
38
+
39
+ _client: ASocksClient
40
+ _country_code: str
41
+ _city: str
42
+ _state: str
43
+ _type_id: int
44
+ _proxy_type_id: int
45
+ _server_port_type_id: int
46
+ _ttl: int
47
+ _traffic_limit: int
48
+
49
+
50
+ def _geo_matches(*, city: str, state: str, port: PortInfo) -> bool:
51
+ """Return ``True`` if *port* is consistent with the requested geo.
52
+
53
+ Conservative: a port is rejected only when the requested city/state is
54
+ set *and* the port reports a different (non-empty) value. Ports whose
55
+ metadata is missing are kept — the server-side filter is trusted for
56
+ those. This guarantees geo-targeting without discarding otherwise-valid
57
+ ports that simply lack city/state metadata.
58
+ """
59
+
60
+ def _mismatch(requested: str, actual: str) -> bool:
61
+ if not (requested and actual):
62
+ return False
63
+ return requested.strip().lower() != actual.strip().lower()
64
+
65
+ return not (_mismatch(city, port.city) or _mismatch(state, port.state))
66
+
67
+
68
+ class PortManagerMixin:
69
+ """Mixin providing shared port fetch/create operations.
70
+
71
+ Requires the consuming class to define:
72
+ - ``_client``: :class:`ASocksClient` instance
73
+ - ``_country_code``, ``_city``, ``_state``: geo filters
74
+ - ``_type_id``, ``_proxy_type_id``, ``_server_port_type_id``: type IDs
75
+ - ``_ttl``, ``_traffic_limit``: port parameters
76
+ """
77
+
78
+ async def _fetch_matching_ports( # type: ignore[misc]
79
+ self: _HasClient,
80
+ limit: int | None = None,
81
+ ) -> list[PortInfo]:
82
+ """Load existing active ports matching the configured criteria.
83
+
84
+ Pages through ``GET /v2/proxy/ports`` (200 per page) until *limit*
85
+ matching ports are collected or the API runs out of pages, then
86
+ applies a client-side geo guard. Passing the full country/state/city
87
+ filter server-side keeps geo-targeting consistent; paginating fixes
88
+ the previous behaviour of only ever seeing the first page.
89
+
90
+ Args:
91
+ limit: Stop once this many matching ports are collected
92
+ (``None`` = collect every matching port).
93
+ """
94
+ per_page = 200
95
+ collected: list[PortInfo] = []
96
+ page = 1
97
+ while True:
98
+ filters = PortFilterParams(
99
+ status=PortStatus.ACTIVE,
100
+ countryName=self._country_code or None,
101
+ stateName=self._state or None,
102
+ cityName=self._city or None,
103
+ page=page,
104
+ per_page=per_page,
105
+ )
106
+ response = await self._client.list_ports(filters)
107
+ page_items = list(response.items)
108
+ collected.extend(
109
+ p for p in page_items if _geo_matches(city=self._city, state=self._state, port=p)
110
+ )
111
+
112
+ if limit is not None and len(collected) >= limit:
113
+ return collected[:limit]
114
+ # Last page reached when the API returns fewer than a full page.
115
+ if len(page_items) < per_page:
116
+ return collected
117
+ page += 1
118
+
119
+ async def _create_ports(self: _HasClient, quantity: int) -> list[PortInfo]: # type: ignore[misc]
120
+ """Create new ports with the configured parameters."""
121
+ request = CreatePortRequest(
122
+ country_code=self._country_code,
123
+ city=self._city,
124
+ state=self._state,
125
+ count=quantity,
126
+ type_id=self._type_id,
127
+ proxy_type_id=self._proxy_type_id,
128
+ server_port_type_id=self._server_port_type_id,
129
+ ttl=self._ttl,
130
+ traffic_limit=self._traffic_limit,
131
+ )
132
+ return await self._client.create_ports(request)
asockslib/benchmark.py ADDED
@@ -0,0 +1,365 @@
1
+ """Proxy health-checking, latency measurement and benchmarking utilities.
2
+
3
+ All operations are fully async and parallelised with ``asyncio.gather``.
4
+
5
+ Public API:
6
+ - :func:`ping_proxy` — measure latency of a single proxy.
7
+ - :func:`benchmark_proxies` — benchmark a batch in parallel.
8
+ - :func:`select_best_proxies` — keep the fastest *N* proxies.
9
+ - :func:`find_best_proxies` — create, test, keep best, delete the rest.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+
20
+ import httpx
21
+ from beartype import beartype
22
+
23
+ # Runtime import (not TYPE_CHECKING): @beartype resolves the ASocksClient
24
+ # annotation at call time, so the name must exist in the module namespace.
25
+ from asockslib.client import ASocksClient # noqa: TC001
26
+ from asockslib.models import CreatePortRequest
27
+
28
+ logger = logging.getLogger("asockslib")
29
+
30
+ ProgressCallback = Callable[[str, int, int], None]
31
+ """``(stage, current, total) -> None`` callback for :func:`find_best_proxies`."""
32
+
33
+ _PING_URLS = [
34
+ "https://api.ipify.org?format=json",
35
+ "https://httpbin.org/ip",
36
+ "https://ifconfig.me/ip",
37
+ ]
38
+
39
+ _PING_TIMEOUT = 5.0
40
+ _BENCHMARK_CONCURRENT = 200
41
+ _DELETE_CONCURRENT = 50
42
+
43
+
44
+ # ── Data classes ──────────────────────────────────────────────────────────── #
45
+
46
+
47
+ @dataclass
48
+ class ProxyBenchmarkResult:
49
+ """Result of benchmarking a single proxy.
50
+
51
+ Attributes:
52
+ proxy_url: Full proxy URL.
53
+ port_id: ASocks port ID.
54
+ latency_ms: Round-trip latency in milliseconds (``None`` on failure).
55
+ external_ip: External IP seen through the proxy.
56
+ is_alive: Whether the proxy responded successfully.
57
+ country: Country name from port metadata.
58
+ city: City name from port metadata.
59
+ error: Error message on failure.
60
+ """
61
+
62
+ proxy_url: str
63
+ port_id: int = 0
64
+ latency_ms: float | None = None
65
+ external_ip: str = ""
66
+ is_alive: bool = False
67
+ country: str = ""
68
+ city: str = ""
69
+ error: str = ""
70
+
71
+
72
+ @dataclass
73
+ class CountryPingResult:
74
+ """Aggregated ping statistics for a country/region."""
75
+
76
+ country_name: str = ""
77
+ country_code: str = ""
78
+ avg_latency_ms: float = 0.0
79
+ min_latency_ms: float = 0.0
80
+ max_latency_ms: float = 0.0
81
+ alive_count: int = 0
82
+ total_count: int = 0
83
+ availability: str = ""
84
+ samples: list[ProxyBenchmarkResult] = field(default_factory=list[ProxyBenchmarkResult])
85
+
86
+
87
+ @dataclass
88
+ class FindBestResult:
89
+ """Result of the :func:`find_best_proxies` pipeline.
90
+
91
+ Attributes:
92
+ best: Top proxies that passed the benchmark.
93
+ total_created: Number of ports created.
94
+ total_tested: Number of proxies tested.
95
+ total_alive: Number of alive proxies.
96
+ total_deleted: Number of discarded ports deleted.
97
+ delete_errors: Port IDs that failed to delete.
98
+ avg_latency_ms: Average latency of the best proxies.
99
+ min_latency_ms: Best (lowest) latency.
100
+ max_latency_ms: Worst latency among the best.
101
+ """
102
+
103
+ best: list[ProxyBenchmarkResult] = field(default_factory=list[ProxyBenchmarkResult])
104
+ total_created: int = 0
105
+ total_tested: int = 0
106
+ total_alive: int = 0
107
+ total_deleted: int = 0
108
+ delete_errors: list[int] = field(default_factory=list[int])
109
+ avg_latency_ms: float = 0.0
110
+ min_latency_ms: float = 0.0
111
+ max_latency_ms: float = 0.0
112
+
113
+
114
+ # ── Single-proxy ping ────────────────────────────────────────────────────── #
115
+
116
+
117
+ @beartype
118
+ async def ping_proxy(
119
+ proxy_url: str,
120
+ *,
121
+ timeout: float = _PING_TIMEOUT,
122
+ ping_urls: list[str] | None = None,
123
+ ) -> ProxyBenchmarkResult:
124
+ """Measure latency of a single proxy.
125
+
126
+ Sends requests to multiple lightweight endpoints in parallel
127
+ and keeps the fastest successful result.
128
+
129
+ Args:
130
+ proxy_url: Full proxy URL.
131
+ timeout: Request timeout in seconds.
132
+ ping_urls: Custom list of check URLs.
133
+ """
134
+ urls = ping_urls or _PING_URLS
135
+ result = ProxyBenchmarkResult(proxy_url=proxy_url)
136
+
137
+ async def _try_url(url: str) -> tuple[float, str] | None:
138
+ start = time.monotonic()
139
+ try:
140
+ async with httpx.AsyncClient(proxy=proxy_url, timeout=timeout) as client:
141
+ resp = await client.get(url)
142
+ elapsed = (time.monotonic() - start) * 1000
143
+ if resp.is_success:
144
+ ip = ""
145
+ try:
146
+ data = resp.json()
147
+ ip = data.get("ip", data.get("origin", ""))
148
+ except Exception: # noqa: BLE001
149
+ ip = resp.text.strip()
150
+ return elapsed, ip
151
+ except Exception: # noqa: BLE001
152
+ pass
153
+ return None
154
+
155
+ ping_results = await asyncio.gather(*[_try_url(u) for u in urls])
156
+
157
+ best: tuple[float, str] | None = None
158
+ for r in ping_results:
159
+ if r is not None and (best is None or r[0] < best[0]):
160
+ best = r
161
+
162
+ if best is not None:
163
+ result.latency_ms = round(best[0], 1)
164
+ result.is_alive = True
165
+ result.external_ip = best[1]
166
+ else:
167
+ result.error = "All ping endpoints failed"
168
+
169
+ return result
170
+
171
+
172
+ # ── Batch benchmark ───────────────────────────────────────────────────────── #
173
+
174
+
175
+ @beartype
176
+ async def benchmark_proxies(
177
+ proxy_urls: list[str],
178
+ *,
179
+ timeout: float = _PING_TIMEOUT,
180
+ concurrency: int = _BENCHMARK_CONCURRENT,
181
+ ) -> list[ProxyBenchmarkResult]:
182
+ """Benchmark a batch of proxies in parallel.
183
+
184
+ Returns:
185
+ Results sorted alive-first by ascending latency.
186
+ """
187
+ semaphore = asyncio.Semaphore(concurrency)
188
+
189
+ async def _bounded_ping(url: str) -> ProxyBenchmarkResult:
190
+ async with semaphore:
191
+ return await ping_proxy(url, timeout=timeout)
192
+
193
+ results = await asyncio.gather(*[_bounded_ping(url) for url in proxy_urls])
194
+
195
+ alive = sorted(
196
+ [r for r in results if r.is_alive],
197
+ key=lambda r: r.latency_ms or float("inf"),
198
+ )
199
+ dead = [r for r in results if not r.is_alive]
200
+ return alive + dead
201
+
202
+
203
+ @beartype
204
+ async def select_best_proxies(
205
+ proxy_urls: list[str],
206
+ *,
207
+ keep: int = 10,
208
+ timeout: float = _PING_TIMEOUT,
209
+ concurrency: int = _BENCHMARK_CONCURRENT,
210
+ ) -> tuple[list[ProxyBenchmarkResult], list[ProxyBenchmarkResult]]:
211
+ """Select the fastest proxies by latency.
212
+
213
+ Returns:
214
+ ``(best, discarded)`` tuple.
215
+ """
216
+ results = await benchmark_proxies(proxy_urls, timeout=timeout, concurrency=concurrency)
217
+ best = results[:keep]
218
+ discarded = results[keep:]
219
+ logger.info(
220
+ "Selected %d best proxies out of %d (discarded %d)",
221
+ len(best),
222
+ len(results),
223
+ len(discarded),
224
+ )
225
+ return best, discarded
226
+
227
+
228
+ # ── Full pipeline ─────────────────────────────────────────────────────────── #
229
+
230
+
231
+ @beartype
232
+ async def find_best_proxies(
233
+ client: ASocksClient,
234
+ *,
235
+ country_code: str = "US",
236
+ city: str = "",
237
+ state: str = "",
238
+ total: int = 100,
239
+ keep: int = 10,
240
+ batch_size: int = 1000,
241
+ timeout: float = 5.0,
242
+ concurrency: int = 200,
243
+ type_id: int = 1,
244
+ proxy_type_id: int = 1,
245
+ server_port_type_id: int = 0,
246
+ ttl: int = 1,
247
+ traffic_limit: int = 1,
248
+ delete_failures: bool = True,
249
+ progress_callback: ProgressCallback | None = None,
250
+ ) -> FindBestResult:
251
+ """Create *total* proxies, benchmark them all, keep the fastest *keep*, delete the rest.
252
+
253
+ The entire pipeline is parallelised: creation in batches, benchmark
254
+ with up to *concurrency* simultaneous pings, deletion in parallel.
255
+
256
+ Args:
257
+ client: :class:`ASocksClient` instance.
258
+ country_code: ISO country code.
259
+ total: How many proxies to create.
260
+ keep: How many fastest proxies to keep.
261
+ timeout: Benchmark timeout per proxy.
262
+ concurrency: Maximum concurrent benchmark connections.
263
+ delete_failures: Delete discarded ports (default ``True``).
264
+ progress_callback: ``(stage, current, total) -> None``.
265
+ """
266
+ result = FindBestResult()
267
+ all_ports: list[tuple[int, str]] = []
268
+
269
+ def _progress(stage: str, current: int, total_n: int) -> None:
270
+ if progress_callback is not None:
271
+ progress_callback(stage, current, total_n)
272
+
273
+ # Step 1: Create proxies in parallel batches
274
+ batches: list[int] = []
275
+ remaining = total
276
+ while remaining > 0:
277
+ chunk = min(remaining, batch_size, 1000)
278
+ batches.append(chunk)
279
+ remaining -= chunk
280
+
281
+ async def _create_batch(chunk: int, batch_num: int) -> list[tuple[int, str]]:
282
+ logger.info("Creating batch %d: %d proxies", batch_num, chunk)
283
+ req = CreatePortRequest(
284
+ country_code=country_code,
285
+ city=city,
286
+ state=state,
287
+ count=chunk,
288
+ type_id=type_id,
289
+ proxy_type_id=proxy_type_id,
290
+ server_port_type_id=server_port_type_id,
291
+ ttl=ttl,
292
+ traffic_limit=traffic_limit,
293
+ )
294
+ try:
295
+ ports = await client.create_ports(req)
296
+ return [(p.id, p.proxy_url) for p in ports]
297
+ except Exception: # noqa: BLE001
298
+ logger.exception("Failed to create batch %d", batch_num)
299
+ return []
300
+
301
+ _progress("creating", 0, total)
302
+ batch_results = await asyncio.gather(
303
+ *[_create_batch(chunk, i + 1) for i, chunk in enumerate(batches)]
304
+ )
305
+ for batch_ports in batch_results:
306
+ all_ports.extend(batch_ports)
307
+ result.total_created = len(all_ports)
308
+ _progress("creating", result.total_created, total)
309
+
310
+ if not all_ports:
311
+ return result
312
+
313
+ # Step 2: Benchmark all proxies
314
+ _progress("benchmarking", 0, len(all_ports))
315
+ url_to_id: dict[str, int] = {url: pid for pid, url in all_ports}
316
+ urls = [url for _, url in all_ports]
317
+
318
+ bench_results = await benchmark_proxies(urls, timeout=timeout, concurrency=concurrency)
319
+ for r in bench_results:
320
+ r.port_id = url_to_id.get(r.proxy_url, 0)
321
+
322
+ result.total_tested = len(bench_results)
323
+ result.total_alive = sum(1 for r in bench_results if r.is_alive)
324
+ _progress("benchmarking", len(bench_results), len(all_ports))
325
+
326
+ # Step 3: Select best
327
+ result.best = bench_results[:keep]
328
+ best_latencies = [r.latency_ms for r in result.best if r.latency_ms is not None]
329
+ if best_latencies:
330
+ result.avg_latency_ms = round(sum(best_latencies) / len(best_latencies), 1)
331
+ result.min_latency_ms = round(min(best_latencies), 1)
332
+ result.max_latency_ms = round(max(best_latencies), 1)
333
+
334
+ best_ids = {r.port_id for r in result.best if r.port_id}
335
+
336
+ # Step 4: Delete discarded ports
337
+ if delete_failures:
338
+ to_delete = [pid for pid, _ in all_ports if pid not in best_ids]
339
+ _progress("deleting", 0, len(to_delete))
340
+ delete_sem = asyncio.Semaphore(_DELETE_CONCURRENT)
341
+
342
+ async def _delete_one(pid: int) -> bool:
343
+ async with delete_sem:
344
+ try:
345
+ await client.delete_port(pid)
346
+ return True
347
+ except Exception: # noqa: BLE001
348
+ return False
349
+
350
+ delete_results = await asyncio.gather(*[_delete_one(pid) for pid in to_delete])
351
+ for pid, ok in zip(to_delete, delete_results, strict=False):
352
+ if ok:
353
+ result.total_deleted += 1
354
+ else:
355
+ result.delete_errors.append(pid)
356
+ _progress("deleting", len(to_delete), len(to_delete))
357
+
358
+ logger.info(
359
+ "find_best_proxies complete: %d best (avg %.0fms), %d deleted, %d errors",
360
+ len(result.best),
361
+ result.avg_latency_ms,
362
+ result.total_deleted,
363
+ len(result.delete_errors),
364
+ )
365
+ return result
@@ -0,0 +1,72 @@
1
+ """CLI for ASocks Proxy API.
2
+
3
+ Two-level interface:
4
+
5
+ - **Main commands** (user-facing):
6
+ ``wizard``, ``get``, ``balance``, ``list``, ``delete``, ``info``
7
+ - **Raw API** (``asocks api ...``):
8
+ low-level ASocks API wrappers for advanced users.
9
+
10
+ Requires ``ASOCKS_API_KEY`` environment variable.
11
+
12
+ Examples::
13
+
14
+ asocks wizard
15
+ asocks get US
16
+ asocks get DE -n 20
17
+ asocks balance
18
+ asocks list
19
+ asocks delete 12345
20
+ asocks api countries
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import typer
26
+
27
+ from asockslib.cli._helpers import (
28
+ ExportFormat,
29
+ apply_proxy_template,
30
+ format_output,
31
+ get_api_key,
32
+ run_async,
33
+ )
34
+ from asockslib.cli.api_commands import register_api_commands
35
+ from asockslib.cli.commands import register_commands
36
+
37
+ # ── App setup ─────────────────────────────────────────────────────────────── #
38
+
39
+ app = typer.Typer(
40
+ name="asocks",
41
+ help="ASocks Proxy — powerful CLI for proxy management.",
42
+ no_args_is_help=True,
43
+ )
44
+
45
+ api_app = typer.Typer(
46
+ name="api",
47
+ help="Raw ASocks API — low-level methods for advanced users.",
48
+ no_args_is_help=True,
49
+ )
50
+ app.add_typer(api_app, name="api")
51
+
52
+ # Register commands from submodules
53
+ register_commands(app)
54
+ register_api_commands(api_app)
55
+
56
+ # Backward-compatible aliases used in tests
57
+ _get_api_key = get_api_key
58
+ _run = run_async
59
+ _format_output = format_output
60
+ _apply_proxy_template = apply_proxy_template
61
+ _copy_to_clipboard = None # Removed; use cli._helpers.copy_to_clipboard
62
+
63
+ __all__ = [
64
+ "ExportFormat",
65
+ "api_app",
66
+ "app",
67
+ ]
68
+
69
+ # ── Entry point ───────────────────────────────────────────────────────────── #
70
+
71
+ if __name__ == "__main__":
72
+ app()