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.
@@ -0,0 +1,667 @@
1
+ """Interactive geo-data selection via fuzzy search.
2
+
3
+ Loads data from ``geo.json`` and provides convenient autocomplete
4
+ prompts for the CLI: country -> state -> city -> ASN.
5
+
6
+ Every prompt is **validated** — only values that exist in the
7
+ geo-data can be submitted.
8
+
9
+ Example::
10
+
11
+ from asockslib.geo_picker import GeoPicker
12
+
13
+ picker = GeoPicker()
14
+ country = picker.pick_country()
15
+ state = picker.pick_state("US")
16
+ city = picker.pick_city("US", "California")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from beartype import beartype
27
+ from prompt_toolkit import PromptSession
28
+ from prompt_toolkit.formatted_text import HTML
29
+
30
+ from asockslib._console import console
31
+ from asockslib.geo_picker._completer import (
32
+ FuzzyMatchCompleter,
33
+ make_choice_validator,
34
+ pt_style,
35
+ )
36
+ from asockslib.geo_picker._messages import PROXY_TEMPLATES, get_message
37
+ from asockslib.geo_picker._types import (
38
+ CONNECTION_TYPES,
39
+ PROXY_TYPES,
40
+ SERVER_PORT_TYPES,
41
+ PickedASN,
42
+ PickedCity,
43
+ PickedConnectionType,
44
+ PickedCountry,
45
+ PickedProxyType,
46
+ PickedServerPortType,
47
+ PickedState,
48
+ )
49
+
50
+ _GEO_JSON_PATH = Path(__file__).parent.parent / "geo.json"
51
+
52
+
53
+ class GeoPicker:
54
+ """Interactive geo-data picker from local ``geo.json``.
55
+
56
+ Loads the geo file once and caches it in memory.
57
+ All ``pick_*`` methods show a fuzzy-search autocomplete prompt
58
+ that displays **all** options when input is empty.
59
+
60
+ Args:
61
+ lang: UI language (only ``"en"`` is supported now).
62
+ geo_json_path: Custom path to ``geo.json``.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ lang: str = "en",
68
+ geo_json_path: Path | None = None,
69
+ ) -> None:
70
+ self._lang = "en"
71
+ self._path = geo_json_path or _GEO_JSON_PATH
72
+ self._data: dict[str, Any] | None = None
73
+
74
+ def _load(self) -> dict[str, Any]:
75
+ """Lazy-load geo.json."""
76
+ data = self._data
77
+ if data is None:
78
+ with open(self._path, encoding="utf-8") as f:
79
+ data = json.load(f)
80
+ self._data = data
81
+ return data
82
+
83
+ # ── Core autocomplete ─────────────────────────────────────────────── #
84
+
85
+ def _autocomplete(
86
+ self,
87
+ message: str,
88
+ choices: list[str],
89
+ meta: dict[str, Any] | None = None,
90
+ ) -> str | None:
91
+ """Run an autocomplete prompt with strict validation.
92
+
93
+ Only values present in *choices* can be submitted.
94
+ Returns ``None`` on Ctrl-C / EOF.
95
+ """
96
+ completer = FuzzyMatchCompleter(choices)
97
+ _lower_lookup: dict[str, str] = {c.strip().lower(): c for c in choices}
98
+
99
+ validator = make_choice_validator(
100
+ choices,
101
+ error_msg=get_message("invalid_choice"),
102
+ error_chars=get_message("invalid_chars"),
103
+ )
104
+
105
+ session: PromptSession[str] = PromptSession(
106
+ completer=completer,
107
+ complete_while_typing=True,
108
+ validator=validator,
109
+ validate_while_typing=False,
110
+ style=pt_style(),
111
+ )
112
+
113
+ def _pre_run() -> None:
114
+ session.default_buffer.start_completion()
115
+
116
+ try:
117
+ result: str = session.prompt(
118
+ HTML(f"<qmark>?</qmark> <question>{message}</question> "),
119
+ pre_run=_pre_run,
120
+ )
121
+ except (KeyboardInterrupt, EOFError):
122
+ return None
123
+
124
+ stripped = result.strip()
125
+ if stripped in choices:
126
+ return stripped
127
+ key = stripped.lower()
128
+ if key in _lower_lookup:
129
+ return _lower_lookup[key]
130
+ return None
131
+
132
+ # ── Public pick methods ───────────────────────────────────────────── #
133
+
134
+ @beartype
135
+ def pick_country(self, message: str | None = None) -> PickedCountry | None:
136
+ """Select a country via fuzzy search."""
137
+ msg = message or get_message("pick_country")
138
+ data = self._load()
139
+
140
+ _title_to_code: dict[str, str] = {}
141
+ titles: list[str] = []
142
+ for code, info in sorted(data.items(), key=lambda x: x[1]["name"]):
143
+ title = f"{info['code']:>2} {info['name']}"
144
+ titles.append(title)
145
+ _title_to_code[title] = code
146
+
147
+ result = self._autocomplete(msg, titles)
148
+ if result is None:
149
+ return None
150
+
151
+ code = _title_to_code.get(result)
152
+ if code and code in data:
153
+ info = data[code]
154
+ return PickedCountry(code=code, name=info["name"], id=info["id"])
155
+
156
+ m = re.match(r"^\s*([A-Z]{2})\s{2,}(.+)$", result.strip())
157
+ if m:
158
+ code = m.group(1)
159
+ if code in data:
160
+ info = data[code]
161
+ return PickedCountry(code=code, name=info["name"], id=info["id"])
162
+ return None
163
+
164
+ @beartype
165
+ def pick_state(
166
+ self,
167
+ country_code: str,
168
+ message: str | None = None,
169
+ allow_skip: bool = True,
170
+ ) -> PickedState | None:
171
+ """Select a state/region via fuzzy search."""
172
+ msg = message or get_message("pick_state")
173
+ data = self._load()
174
+ country = data.get(country_code.upper())
175
+ if not country:
176
+ return None
177
+
178
+ states = country.get("states", {})
179
+ state_items = [
180
+ (name, sdata) for name, sdata in sorted(states.items()) if name != "_no_state"
181
+ ]
182
+
183
+ if not state_items:
184
+ console.print(f"[dim]{get_message('no_states')}[/dim]")
185
+ return None
186
+
187
+ skip_label = get_message("skip")
188
+ cities_word = get_message("cities_suffix")
189
+
190
+ _title_to_state: dict[str, str] = {}
191
+ titles: list[str] = []
192
+ if allow_skip:
193
+ titles.append(skip_label)
194
+ for name, sdata in state_items:
195
+ city_count = len(sdata.get("cities", []))
196
+ title = f"{name} ({city_count} {cities_word})"
197
+ titles.append(title)
198
+ _title_to_state[title] = name
199
+
200
+ result = self._autocomplete(msg, titles)
201
+ if result is None or result.startswith("⏭"):
202
+ return None
203
+
204
+ state_name = _title_to_state.get(result)
205
+ if state_name is None:
206
+ m = re.match(r"^(.+?)\s{2}\(\d+\s", result)
207
+ if m:
208
+ state_name = m.group(1).strip()
209
+
210
+ if state_name is None:
211
+ return None
212
+
213
+ for name, sdata in state_items:
214
+ if name == state_name or name.lower() == state_name.lower():
215
+ return PickedState(name=name, id=sdata["id"])
216
+ return None
217
+
218
+ @beartype
219
+ def pick_city(
220
+ self,
221
+ country_code: str,
222
+ state_name: str | None = None,
223
+ message: str | None = None,
224
+ allow_skip: bool = True,
225
+ ) -> PickedCity | None:
226
+ """Select a city via fuzzy search."""
227
+ msg = message or get_message("pick_city")
228
+ data = self._load()
229
+ country = data.get(country_code.upper())
230
+ if not country:
231
+ return None
232
+
233
+ cities: list[dict[str, Any]] = []
234
+ states = country.get("states", {})
235
+
236
+ if state_name:
237
+ state = states.get(state_name)
238
+ if not state:
239
+ for sname, sdata in states.items():
240
+ if sname.lower() == state_name.lower():
241
+ state = sdata
242
+ break
243
+ if state:
244
+ cities = state.get("cities", [])
245
+ else:
246
+ for sdata in states.values():
247
+ cities.extend(sdata.get("cities", []))
248
+
249
+ if not cities:
250
+ console.print(f"[dim]{get_message('no_cities')}[/dim]")
251
+ return None
252
+
253
+ sorted_cities = sorted(cities, key=lambda x: x["name"])
254
+ skip_label = get_message("skip")
255
+
256
+ _name_to_city: dict[str, dict[str, Any]] = {}
257
+ _lower_to_city: dict[str, dict[str, Any]] = {}
258
+ titles: list[str] = []
259
+ if allow_skip:
260
+ titles.append(skip_label)
261
+ for city in sorted_cities:
262
+ cname = city["name"]
263
+ titles.append(cname)
264
+ _name_to_city[cname] = city
265
+ _lower_to_city[cname.lower()] = city
266
+
267
+ result = self._autocomplete(msg, titles)
268
+ if result is None or result.startswith("⏭"):
269
+ return None
270
+
271
+ city_data = _name_to_city.get(result)
272
+ if city_data is None:
273
+ city_data = _lower_to_city.get(result.strip().lower())
274
+
275
+ if city_data is not None:
276
+ return PickedCity(name=city_data["name"], id=city_data["id"])
277
+ return None
278
+
279
+ @beartype
280
+ def pick_asn(
281
+ self,
282
+ country_code: str,
283
+ message: str | None = None,
284
+ allow_skip: bool = True,
285
+ ) -> PickedASN | None:
286
+ """Select an ASN provider via fuzzy search."""
287
+ msg = message or get_message("pick_asn")
288
+ data = self._load()
289
+ country = data.get(country_code.upper())
290
+ if not country:
291
+ return None
292
+
293
+ asns = country.get("asns", [])
294
+ if not asns:
295
+ console.print(f"[dim]{get_message('no_asns')}[/dim]")
296
+ return None
297
+
298
+ sorted_asns = sorted(asns, key=lambda x: x["asn"])
299
+ skip_label = get_message("skip")
300
+
301
+ _label_to_asn: dict[str, dict[str, Any]] = {}
302
+ titles: list[str] = []
303
+ if allow_skip:
304
+ titles.append(skip_label)
305
+ for asn in sorted_asns:
306
+ name = asn.get("name", "")
307
+ label = f"AS{asn['asn']}" + (f" {name}" if name else "")
308
+ titles.append(label)
309
+ _label_to_asn[label] = asn
310
+
311
+ result = self._autocomplete(msg, titles)
312
+ if result is None or result.startswith("⏭"):
313
+ return None
314
+
315
+ asn_data = _label_to_asn.get(result)
316
+ if asn_data is None:
317
+ rl = result.strip().lower()
318
+ for label, adata in _label_to_asn.items():
319
+ if label.lower() == rl:
320
+ asn_data = adata
321
+ break
322
+
323
+ if asn_data is not None:
324
+ return PickedASN(number=asn_data["asn"], name=asn_data.get("name", ""))
325
+ return None
326
+
327
+ # ── Type pickers ──────────────────────────────────────────────────── #
328
+
329
+ @beartype
330
+ def _pick_from_list(
331
+ self,
332
+ message: str,
333
+ items: list[tuple[int, str]],
334
+ ) -> tuple[int, str] | None:
335
+ """Generic picker for static (id, label) lists."""
336
+ _label_to_id: dict[str, int] = {}
337
+ titles: list[str] = []
338
+ for type_id, label in items:
339
+ titles.append(label)
340
+ _label_to_id[label] = type_id
341
+
342
+ result = self._autocomplete(message, titles)
343
+ if result is None:
344
+ return None
345
+
346
+ tid = _label_to_id.get(result)
347
+ if tid is None:
348
+ rl = result.strip().lower()
349
+ for label, t in _label_to_id.items():
350
+ if label.lower() == rl:
351
+ tid = t
352
+ result = label
353
+ break
354
+ if tid is not None:
355
+ return tid, result
356
+ return None
357
+
358
+ @beartype
359
+ def pick_connection_type(self, message: str | None = None) -> PickedConnectionType | None:
360
+ """Select a connection type."""
361
+ msg = message or get_message("pick_connection_type")
362
+ picked = self._pick_from_list(msg, CONNECTION_TYPES)
363
+ if picked:
364
+ return PickedConnectionType(id=picked[0], label=picked[1])
365
+ return None
366
+
367
+ @beartype
368
+ def pick_proxy_type(self, message: str | None = None) -> PickedProxyType | None:
369
+ """Select a proxy type."""
370
+ msg = message or get_message("pick_proxy_type")
371
+ picked = self._pick_from_list(msg, PROXY_TYPES)
372
+ if picked:
373
+ return PickedProxyType(id=picked[0], label=picked[1])
374
+ return None
375
+
376
+ @beartype
377
+ def pick_server_port_type(self, message: str | None = None) -> PickedServerPortType | None:
378
+ """Select a server port type."""
379
+ msg = message or get_message("pick_server_port_type")
380
+ picked = self._pick_from_list(msg, SERVER_PORT_TYPES)
381
+ if picked:
382
+ return PickedServerPortType(id=picked[0], label=picked[1])
383
+ return None
384
+
385
+ # ── Free-text input ───────────────────────────────────────────────── #
386
+
387
+ @beartype
388
+ def _free_input(
389
+ self,
390
+ message: str,
391
+ default: str = "",
392
+ allow_empty: bool = True,
393
+ ) -> str:
394
+ """Show a free-text input prompt."""
395
+ session: PromptSession[str] = PromptSession(style=pt_style())
396
+ placeholder = HTML(f"<ansigray>{default}</ansigray>") if default else None
397
+ try:
398
+ result: str = session.prompt(
399
+ HTML(f"<qmark>?</qmark> <question>{message}</question> "),
400
+ placeholder=placeholder,
401
+ )
402
+ except (KeyboardInterrupt, EOFError):
403
+ return default
404
+
405
+ stripped = result.strip()
406
+ if not stripped and allow_empty:
407
+ return default
408
+ return stripped
409
+
410
+ # ── Action / number / misc pickers ────────────────────────────────── #
411
+
412
+ @beartype
413
+ def pick_action(self, message: str | None = None) -> str | None:
414
+ """Pick wizard action: create proxies or find best proxies.
415
+
416
+ Returns ``"create"``, ``"best"`` or ``None``.
417
+ """
418
+ msg = message or get_message("pick_action")
419
+ action_create = get_message("action_create")
420
+ action_best = get_message("action_best")
421
+ choices = [action_create, action_best]
422
+
423
+ result = self._autocomplete(msg, choices)
424
+ if result is None:
425
+ return None
426
+ if result == action_best:
427
+ return "best"
428
+ return "create"
429
+
430
+ @beartype
431
+ def pick_count(self, message: str | None = None, default: int = 10) -> int:
432
+ """Pick number of proxies to create."""
433
+ msg = message or get_message("pick_count")
434
+ choices = ["1", "5", "10", "25", "50", "100", "250", "500", "1000"]
435
+ result = self._autocomplete(msg, choices)
436
+ if result and result.isdigit():
437
+ return int(result)
438
+ return default
439
+
440
+ @beartype
441
+ def pick_keep(self, message: str | None = None, default: int = 10) -> int:
442
+ """Pick how many best proxies to keep."""
443
+ msg = message or get_message("pick_keep")
444
+ choices = ["1", "3", "5", "10", "20", "50"]
445
+ result = self._autocomplete(msg, choices)
446
+ if result and result.isdigit():
447
+ return int(result)
448
+ return default
449
+
450
+ @beartype
451
+ def pick_name(self, message: str | None = None) -> str:
452
+ """Pick port name (free text, optional)."""
453
+ msg = message or get_message("pick_name")
454
+ return self._free_input(msg, default="", allow_empty=True)
455
+
456
+ @beartype
457
+ def pick_format(self, message: str | None = None) -> str:
458
+ """Pick export format (``txt``, ``json``, ``csv``)."""
459
+ msg = message or get_message("pick_format")
460
+ labels = [
461
+ "txt — one proxy per line",
462
+ "json — JSON array",
463
+ "csv — CSV with header",
464
+ ]
465
+ result = self._autocomplete(msg, labels)
466
+ if result:
467
+ return result.split(" ")[0]
468
+ return "txt"
469
+
470
+ @beartype
471
+ def pick_output(self, message: str | None = None) -> str:
472
+ """Pick output file path (optional)."""
473
+ msg = message or get_message("pick_output")
474
+ return self._free_input(msg, default="", allow_empty=True)
475
+
476
+ @beartype
477
+ def pick_timeout(self, message: str | None = None, default: float = 15.0) -> float:
478
+ """Pick benchmark timeout."""
479
+ msg = message or get_message("pick_timeout")
480
+ choices = ["5", "10", "15", "30", "60"]
481
+ result = self._autocomplete(msg, choices)
482
+ if result and result.replace(".", "", 1).isdigit():
483
+ return float(result)
484
+ return default
485
+
486
+ @beartype
487
+ def pick_concurrency(self, message: str | None = None, default: int = 50) -> int:
488
+ """Pick benchmark concurrency."""
489
+ msg = message or get_message("pick_concurrency")
490
+ choices = ["10", "25", "50", "100", "200"]
491
+ result = self._autocomplete(msg, choices)
492
+ if result and result.isdigit():
493
+ return int(result)
494
+ return default
495
+
496
+ # ── Proxy template picker ─────────────────────────────────────────── #
497
+
498
+ @beartype
499
+ def pick_proxy_template(self, message: str | None = None) -> str:
500
+ """Select a proxy output template.
501
+
502
+ Returns the template string, e.g.
503
+ ``"{protocol}://{login}:{password}@{ip}:{port}"``.
504
+ """
505
+ msg = message or get_message("pick_proxy_template")
506
+ _label_to_tpl: dict[str, str] = {}
507
+ titles: list[str] = []
508
+ for tpl, label in PROXY_TEMPLATES:
509
+ titles.append(label)
510
+ _label_to_tpl[label] = tpl
511
+
512
+ result = self._autocomplete(msg, titles)
513
+ if result is None:
514
+ return "{protocol}://{login}:{password}@{ip}:{port}"
515
+
516
+ tpl = _label_to_tpl.get(result)
517
+ if tpl is None:
518
+ rl = result.strip().lower()
519
+ for label, t in _label_to_tpl.items():
520
+ if label.lower() == rl:
521
+ tpl = t
522
+ break
523
+ return tpl or "{protocol}://{login}:{password}@{ip}:{port}"
524
+
525
+ # ── Full wizards ──────────────────────────────────────────────────── #
526
+
527
+ @beartype
528
+ def pick_full(self) -> dict[str, str | int | None]:
529
+ """Full wizard: connection type -> proxy type -> port type -> geo."""
530
+ result: dict[str, str | int | None] = {
531
+ "country_code": None,
532
+ "country_name": None,
533
+ "state": None,
534
+ "city": None,
535
+ "city_id": None,
536
+ "type_id": None,
537
+ "proxy_type_id": None,
538
+ "server_port_type_id": None,
539
+ "traffic_limit": None,
540
+ }
541
+
542
+ conn = self.pick_connection_type()
543
+ if conn:
544
+ result["type_id"] = conn.id
545
+
546
+ ptype = self.pick_proxy_type()
547
+ if ptype:
548
+ result["proxy_type_id"] = ptype.id
549
+
550
+ spt = self.pick_server_port_type()
551
+ if spt:
552
+ result["server_port_type_id"] = spt.id
553
+
554
+ country = self.pick_country()
555
+ if not country:
556
+ return result
557
+ result["country_code"] = country.code
558
+ result["country_name"] = country.name
559
+
560
+ state = self.pick_state(country.code)
561
+ if state:
562
+ result["state"] = state.name
563
+
564
+ city = self.pick_city(
565
+ country.code,
566
+ state_name=state.name if state else None,
567
+ )
568
+ if city:
569
+ result["city"] = city.name
570
+ result["city_id"] = city.id
571
+
572
+ if result.get("server_port_type_id") == 1:
573
+ traffic_msg = get_message("pick_traffic_limit")
574
+ traffic_choices = ["1", "5", "10", "25", "50", "100", "250", "500", "1000"]
575
+ traffic_result = self._autocomplete(traffic_msg, traffic_choices)
576
+ if traffic_result and traffic_result.isdigit():
577
+ result["traffic_limit"] = int(traffic_result)
578
+
579
+ return result
580
+
581
+ @beartype
582
+ def pick_wizard(self) -> dict[str, str | int | float | None]:
583
+ """Full interactive wizard — everything in one flow.
584
+
585
+ Returns a dict with all wizard parameters.
586
+ """
587
+ result: dict[str, str | int | float | None] = {
588
+ "action": None,
589
+ "country_code": None,
590
+ "country_name": None,
591
+ "state": None,
592
+ "city": None,
593
+ "city_id": None,
594
+ "type_id": 1,
595
+ "proxy_type_id": 1,
596
+ "server_port_type_id": 0,
597
+ "traffic_limit": 10,
598
+ "count": 10,
599
+ "keep": 10,
600
+ "ttl": 1,
601
+ "name": "",
602
+ "format": "txt",
603
+ "output": "",
604
+ "timeout": 15.0,
605
+ "concurrency": 50,
606
+ "proxy_template": "{protocol}://{login}:{password}@{ip}:{port}",
607
+ }
608
+
609
+ action = self.pick_action()
610
+ if action is None:
611
+ return result
612
+ result["action"] = action
613
+
614
+ ptype = self.pick_proxy_type()
615
+ if ptype:
616
+ result["proxy_type_id"] = ptype.id
617
+
618
+ conn = self.pick_connection_type()
619
+ if conn:
620
+ result["type_id"] = conn.id
621
+
622
+ spt = self.pick_server_port_type()
623
+ if spt:
624
+ result["server_port_type_id"] = spt.id
625
+
626
+ country = self.pick_country()
627
+ if not country:
628
+ return result
629
+ result["country_code"] = country.code
630
+ result["country_name"] = country.name
631
+
632
+ state = self.pick_state(country.code)
633
+ if state:
634
+ result["state"] = state.name
635
+
636
+ city = self.pick_city(
637
+ country.code,
638
+ state_name=state.name if state else None,
639
+ )
640
+ if city:
641
+ result["city"] = city.name
642
+ result["city_id"] = city.id
643
+
644
+ if action == "best":
645
+ result["count"] = self.pick_count(default=100)
646
+ result["keep"] = self.pick_keep(default=10)
647
+ else:
648
+ result["count"] = self.pick_count(default=10)
649
+
650
+ result["name"] = self.pick_name()
651
+
652
+ if result.get("server_port_type_id") == 1:
653
+ traffic_msg = get_message("pick_traffic_limit")
654
+ traffic_choices = ["1", "5", "10", "25", "50", "100", "250", "500", "1000"]
655
+ traffic_result = self._autocomplete(traffic_msg, traffic_choices)
656
+ if traffic_result and traffic_result.isdigit():
657
+ result["traffic_limit"] = int(traffic_result)
658
+
659
+ result["proxy_template"] = self.pick_proxy_template()
660
+ result["format"] = self.pick_format()
661
+ result["output"] = self.pick_output()
662
+
663
+ if action == "best":
664
+ result["timeout"] = self.pick_timeout()
665
+ result["concurrency"] = self.pick_concurrency()
666
+
667
+ return result