talks-reducer 0.7.1__py3-none-any.whl → 0.8.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.
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.7.1"
5
+ __version__ = "0.8.0"
talks_reducer/cli.py CHANGED
@@ -10,10 +10,9 @@ import sys
10
10
  import time
11
11
  from importlib import import_module
12
12
  from pathlib import Path
13
- from typing import Dict, List, Optional, Sequence, Tuple
13
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
14
14
 
15
15
  from . import audio
16
-
17
16
  from .ffmpeg import FFmpegNotFoundError
18
17
  from .models import ProcessingOptions, default_temp_folder
19
18
  from .pipeline import speed_up_video
@@ -141,116 +140,215 @@ def _print_total_time(start_time: float) -> None:
141
140
  print(f"\nTime: {int(hours)}h {int(minutes)}m {seconds:.2f}s")
142
141
 
143
142
 
144
- def _process_via_server(
145
- files: Sequence[str], parsed_args: argparse.Namespace, *, start_time: float
146
- ) -> Tuple[bool, Optional[str]]:
147
- """Upload *files* to the configured server and download the results.
143
+ class CliApplication:
144
+ """Coordinator for CLI processing with dependency injection support."""
148
145
 
149
- Returns a tuple of (success, error_message). When *success* is ``False``,
150
- ``error_message`` contains the reason and the caller should fall back to the
151
- local processing pipeline.
152
- """
146
+ def __init__(
147
+ self,
148
+ *,
149
+ gather_files: Callable[[List[str]], List[str]],
150
+ send_video: Optional[Callable[..., Tuple[Path, str, str]]],
151
+ speed_up: Callable[[ProcessingOptions, object], object],
152
+ reporter_factory: Callable[[], object],
153
+ remote_error_message: Optional[str] = None,
154
+ ) -> None:
155
+ self._gather_files = gather_files
156
+ self._send_video = send_video
157
+ self._speed_up = speed_up
158
+ self._reporter_factory = reporter_factory
159
+ self._remote_error_message = remote_error_message
153
160
 
154
- try:
155
- from . import service_client
156
- except ImportError as exc: # pragma: no cover - optional dependency guard
157
- return False, ("Server mode requires the gradio_client dependency. " f"({exc})")
158
-
159
- server_url = parsed_args.server_url
160
- if not server_url:
161
- return False, "Server URL was not provided."
162
-
163
- output_override: Optional[Path] = None
164
- if parsed_args.output_file and len(files) == 1:
165
- output_override = Path(parsed_args.output_file).expanduser()
166
- elif parsed_args.output_file and len(files) > 1:
167
- print(
168
- "Warning: --output is ignored when processing multiple files via the server.",
169
- file=sys.stderr,
170
- )
161
+ def run(self, parsed_args: argparse.Namespace) -> Tuple[int, List[str]]:
162
+ """Execute the CLI pipeline for *parsed_args*."""
171
163
 
172
- remote_option_values: Dict[str, float] = {}
173
- if parsed_args.silent_threshold is not None:
174
- remote_option_values["silent_threshold"] = float(parsed_args.silent_threshold)
175
- if parsed_args.silent_speed is not None:
176
- remote_option_values["silent_speed"] = float(parsed_args.silent_speed)
177
- if parsed_args.sounded_speed is not None:
178
- remote_option_values["sounded_speed"] = float(parsed_args.sounded_speed)
179
-
180
- unsupported_options = []
181
- for name in (
182
- "frame_spreadage",
183
- "sample_rate",
184
- "temp_folder",
185
- ):
186
- if getattr(parsed_args, name) is not None:
187
- unsupported_options.append(f"--{name.replace('_', '-')}")
188
-
189
- if unsupported_options:
190
- print(
191
- "Warning: the following options are ignored when using --url: "
192
- + ", ".join(sorted(unsupported_options)),
193
- file=sys.stderr,
194
- )
164
+ start_time = time.time()
165
+ files = self._gather_files(parsed_args.input_file)
195
166
 
196
- for index, file in enumerate(files, start=1):
197
- basename = os.path.basename(file)
198
- print(
199
- f"Processing file {index}/{len(files)} '{basename}' via server {server_url}"
200
- )
201
- printed_log_header = False
202
- progress_state: dict[str, tuple[Optional[int], Optional[int], str]] = {}
203
- stream_updates = bool(getattr(parsed_args, "server_stream", False))
204
-
205
- def _stream_server_log(line: str) -> None:
206
- nonlocal printed_log_header
207
- if not printed_log_header:
208
- print("\nServer log:", flush=True)
209
- printed_log_header = True
210
- print(line, flush=True)
211
-
212
- def _stream_progress(
213
- desc: str, current: Optional[int], total: Optional[int], unit: str
214
- ) -> None:
215
- key = desc or "Processing"
216
- state = (current, total, unit)
217
- if progress_state.get(key) == state:
218
- return
219
- progress_state[key] = state
220
-
221
- parts: list[str] = []
222
- if current is not None and total and total > 0:
223
- percent = (current / total) * 100
224
- parts.append(f"{current}/{total}")
225
- parts.append(f"{percent:.1f}%")
226
- elif current is not None:
227
- parts.append(str(current))
228
- if unit:
229
- parts.append(unit)
230
- message = " ".join(parts).strip()
231
- print(f"{key}: {message or 'update'}", flush=True)
232
-
233
- try:
234
- destination, summary, log_text = service_client.send_video(
235
- input_path=Path(file),
236
- output_path=output_override,
237
- server_url=server_url,
238
- small=bool(parsed_args.small),
239
- **remote_option_values,
240
- log_callback=_stream_server_log,
241
- stream_updates=stream_updates,
242
- progress_callback=_stream_progress if stream_updates else None,
167
+ args: Dict[str, object] = {
168
+ key: value for key, value in vars(parsed_args).items() if value is not None
169
+ }
170
+ del args["input_file"]
171
+
172
+ if "host" in args:
173
+ del args["host"]
174
+
175
+ if len(files) > 1 and "output_file" in args:
176
+ del args["output_file"]
177
+
178
+ error_messages: List[str] = []
179
+ reporter_logs: List[str] = []
180
+
181
+ if getattr(parsed_args, "server_url", None):
182
+ remote_success, remote_errors, fallback_logs = self._process_via_server(
183
+ files, parsed_args, start_time
184
+ )
185
+ error_messages.extend(remote_errors)
186
+ reporter_logs.extend(fallback_logs)
187
+ if remote_success:
188
+ return 0, error_messages
189
+
190
+ reporter = self._reporter_factory()
191
+ for message in reporter_logs:
192
+ reporter.log(message)
193
+
194
+ for index, file in enumerate(files):
195
+ print(
196
+ f"Processing file {index + 1}/{len(files)} '{os.path.basename(file)}'"
197
+ )
198
+ local_options = dict(args)
199
+
200
+ option_kwargs: Dict[str, object] = {"input_file": Path(file)}
201
+
202
+ if "output_file" in local_options:
203
+ option_kwargs["output_file"] = Path(local_options["output_file"])
204
+ if "temp_folder" in local_options:
205
+ option_kwargs["temp_folder"] = Path(local_options["temp_folder"])
206
+ if "silent_threshold" in local_options:
207
+ option_kwargs["silent_threshold"] = float(
208
+ local_options["silent_threshold"]
209
+ )
210
+ if "silent_speed" in local_options:
211
+ option_kwargs["silent_speed"] = float(local_options["silent_speed"])
212
+ if "sounded_speed" in local_options:
213
+ option_kwargs["sounded_speed"] = float(local_options["sounded_speed"])
214
+ if "frame_spreadage" in local_options:
215
+ option_kwargs["frame_spreadage"] = int(local_options["frame_spreadage"])
216
+ if "sample_rate" in local_options:
217
+ option_kwargs["sample_rate"] = int(local_options["sample_rate"])
218
+ if "small" in local_options:
219
+ option_kwargs["small"] = bool(local_options["small"])
220
+ options = ProcessingOptions(**option_kwargs)
221
+
222
+ try:
223
+ result = self._speed_up(options, reporter=reporter)
224
+ except FFmpegNotFoundError as exc:
225
+ message = str(exc)
226
+ return 1, [*error_messages, message]
227
+
228
+ reporter.log(f"Completed: {result.output_file}")
229
+ summary_parts: List[str] = []
230
+ time_ratio = getattr(result, "time_ratio", None)
231
+ size_ratio = getattr(result, "size_ratio", None)
232
+ if time_ratio is not None:
233
+ summary_parts.append(f"{time_ratio * 100:.0f}% time")
234
+ if size_ratio is not None:
235
+ summary_parts.append(f"{size_ratio * 100:.0f}% size")
236
+ if summary_parts:
237
+ reporter.log("Result: " + ", ".join(summary_parts))
238
+
239
+ _print_total_time(start_time)
240
+ return 0, error_messages
241
+
242
+ def _process_via_server(
243
+ self,
244
+ files: Sequence[str],
245
+ parsed_args: argparse.Namespace,
246
+ start_time: float,
247
+ ) -> Tuple[bool, List[str], List[str]]:
248
+ """Upload *files* to the configured server and download the results."""
249
+
250
+ if not self._send_video:
251
+ message = self._remote_error_message or "Server processing is unavailable."
252
+ fallback_notice = "Falling back to local processing pipeline."
253
+ return False, [message, fallback_notice], [message, fallback_notice]
254
+
255
+ server_url = parsed_args.server_url
256
+ if not server_url:
257
+ message = "Server URL was not provided."
258
+ fallback_notice = "Falling back to local processing pipeline."
259
+ return False, [message, fallback_notice], [message, fallback_notice]
260
+
261
+ output_override: Optional[Path] = None
262
+ if parsed_args.output_file and len(files) == 1:
263
+ output_override = Path(parsed_args.output_file).expanduser()
264
+ elif parsed_args.output_file and len(files) > 1:
265
+ print(
266
+ "Warning: --output is ignored when processing multiple files via the server.",
267
+ file=sys.stderr,
243
268
  )
244
- except Exception as exc: # pragma: no cover - network failure safeguard
245
- return False, f"Failed to process {basename} via server: {exc}"
246
269
 
247
- print(summary)
248
- print(f"Saved processed video to {destination}")
249
- if log_text.strip() and not printed_log_header:
250
- print("\nServer log:\n" + log_text)
270
+ remote_option_values: Dict[str, float] = {}
271
+ if parsed_args.silent_threshold is not None:
272
+ remote_option_values["silent_threshold"] = float(
273
+ parsed_args.silent_threshold
274
+ )
275
+ if parsed_args.silent_speed is not None:
276
+ remote_option_values["silent_speed"] = float(parsed_args.silent_speed)
277
+ if parsed_args.sounded_speed is not None:
278
+ remote_option_values["sounded_speed"] = float(parsed_args.sounded_speed)
279
+
280
+ unsupported_options: List[str] = []
281
+ for name in ("frame_spreadage", "sample_rate", "temp_folder"):
282
+ if getattr(parsed_args, name) is not None:
283
+ unsupported_options.append(f"--{name.replace('_', '-')}")
284
+
285
+ if unsupported_options:
286
+ print(
287
+ "Warning: the following options are ignored when using --url: "
288
+ + ", ".join(sorted(unsupported_options)),
289
+ file=sys.stderr,
290
+ )
251
291
 
252
- _print_total_time(start_time)
253
- return True, None
292
+ for index, file in enumerate(files, start=1):
293
+ basename = os.path.basename(file)
294
+ print(
295
+ f"Processing file {index}/{len(files)} '{basename}' via server {server_url}"
296
+ )
297
+ printed_log_header = False
298
+ progress_state: dict[str, tuple[Optional[int], Optional[int], str]] = {}
299
+ stream_updates = bool(getattr(parsed_args, "server_stream", False))
300
+
301
+ def _stream_server_log(line: str) -> None:
302
+ nonlocal printed_log_header
303
+ if not printed_log_header:
304
+ print("\nServer log:", flush=True)
305
+ printed_log_header = True
306
+ print(line, flush=True)
307
+
308
+ def _stream_progress(
309
+ desc: str, current: Optional[int], total: Optional[int], unit: str
310
+ ) -> None:
311
+ key = desc or "Processing"
312
+ state = (current, total, unit)
313
+ if progress_state.get(key) == state:
314
+ return
315
+ progress_state[key] = state
316
+
317
+ parts: List[str] = []
318
+ if current is not None and total and total > 0:
319
+ percent = (current / total) * 100
320
+ parts.append(f"{current}/{total}")
321
+ parts.append(f"{percent:.1f}%")
322
+ elif current is not None:
323
+ parts.append(str(current))
324
+ if unit:
325
+ parts.append(unit)
326
+ message = " ".join(parts).strip()
327
+ print(f"{key}: {message or 'update'}", flush=True)
328
+
329
+ try:
330
+ destination, summary, log_text = self._send_video(
331
+ input_path=Path(file),
332
+ output_path=output_override,
333
+ server_url=server_url,
334
+ small=bool(parsed_args.small),
335
+ **remote_option_values,
336
+ log_callback=_stream_server_log,
337
+ stream_updates=stream_updates,
338
+ progress_callback=_stream_progress if stream_updates else None,
339
+ )
340
+ except Exception as exc: # pragma: no cover - network failure safeguard
341
+ message = f"Failed to process {basename} via server: {exc}"
342
+ fallback_notice = "Falling back to local processing pipeline."
343
+ return False, [message, fallback_notice], [message, fallback_notice]
344
+
345
+ print(summary)
346
+ print(f"Saved processed video to {destination}")
347
+ if log_text.strip() and not printed_log_header:
348
+ print("\nServer log:\n" + log_text)
349
+
350
+ _print_total_time(start_time)
351
+ return True, [], []
254
352
 
255
353
 
256
354
  def _launch_gui(argv: Sequence[str]) -> bool:
@@ -421,84 +519,30 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
421
519
  if host_value:
422
520
  parsed_args.server_url = f"http://{host_value}:9005"
423
521
 
424
- start_time = time.time()
425
-
426
- files = gather_input_files(parsed_args.input_file)
427
-
428
- args: Dict[str, object] = {
429
- k: v for k, v in vars(parsed_args).items() if v is not None
430
- }
431
- del args["input_file"]
432
-
433
- if "host" in args:
434
- del args["host"]
435
-
436
- if len(files) > 1 and "output_file" in args:
437
- del args["output_file"]
438
-
439
- fallback_messages: List[str] = []
440
- if parsed_args.server_url:
441
- server_success, error_message = _process_via_server(
442
- files, parsed_args, start_time=start_time
522
+ send_video = None
523
+ remote_error_message: Optional[str] = None
524
+ try: # pragma: no cover - optional dependency guard
525
+ from . import service_client
526
+ except ImportError as exc:
527
+ remote_error_message = (
528
+ "Server mode requires the gradio_client dependency. " f"({exc})"
443
529
  )
444
- if server_success:
445
- return
446
-
447
- fallback_reason = error_message or "Server processing is unavailable."
448
- print(fallback_reason, file=sys.stderr)
449
- fallback_messages.append(fallback_reason)
450
-
451
- fallback_notice = "Falling back to local processing pipeline."
452
- print(fallback_notice, file=sys.stderr)
453
- fallback_messages.append(fallback_notice)
454
-
455
- reporter = TqdmProgressReporter()
456
-
457
- for message in fallback_messages:
458
- reporter.log(message)
459
-
460
- for index, file in enumerate(files):
461
- print(f"Processing file {index + 1}/{len(files)} '{os.path.basename(file)}'")
462
- local_options = dict(args)
463
-
464
- option_kwargs: Dict[str, object] = {"input_file": Path(file)}
465
-
466
- if "output_file" in local_options:
467
- option_kwargs["output_file"] = Path(local_options["output_file"])
468
- if "temp_folder" in local_options:
469
- option_kwargs["temp_folder"] = Path(local_options["temp_folder"])
470
- if "silent_threshold" in local_options:
471
- option_kwargs["silent_threshold"] = float(local_options["silent_threshold"])
472
- if "silent_speed" in local_options:
473
- option_kwargs["silent_speed"] = float(local_options["silent_speed"])
474
- if "sounded_speed" in local_options:
475
- option_kwargs["sounded_speed"] = float(local_options["sounded_speed"])
476
- if "frame_spreadage" in local_options:
477
- option_kwargs["frame_spreadage"] = int(local_options["frame_spreadage"])
478
- if "sample_rate" in local_options:
479
- option_kwargs["sample_rate"] = int(local_options["sample_rate"])
480
- if "small" in local_options:
481
- option_kwargs["small"] = bool(local_options["small"])
482
- options = ProcessingOptions(**option_kwargs)
483
-
484
- try:
485
- result = speed_up_video(options, reporter=reporter)
486
- except FFmpegNotFoundError as exc:
487
- print(str(exc), file=sys.stderr)
488
- sys.exit(1)
530
+ else:
531
+ send_video = service_client.send_video
532
+
533
+ application = CliApplication(
534
+ gather_files=gather_input_files,
535
+ send_video=send_video,
536
+ speed_up=speed_up_video,
537
+ reporter_factory=TqdmProgressReporter,
538
+ remote_error_message=remote_error_message,
539
+ )
489
540
 
490
- reporter.log(f"Completed: {result.output_file}")
491
- summary_parts = []
492
- time_ratio = getattr(result, "time_ratio", None)
493
- size_ratio = getattr(result, "size_ratio", None)
494
- if time_ratio is not None:
495
- summary_parts.append(f"{time_ratio * 100:.0f}% time")
496
- if size_ratio is not None:
497
- summary_parts.append(f"{size_ratio * 100:.0f}% size")
498
- if summary_parts:
499
- reporter.log("Result: " + ", ".join(summary_parts))
500
-
501
- _print_total_time(start_time)
541
+ exit_code, error_messages = application.run(parsed_args)
542
+ for message in error_messages:
543
+ print(message, file=sys.stderr)
544
+ if exit_code:
545
+ sys.exit(exit_code)
502
546
 
503
547
 
504
548
  if __name__ == "__main__":
@@ -7,7 +7,7 @@ import socket
7
7
  from concurrent.futures import ThreadPoolExecutor
8
8
  from contextlib import closing
9
9
  from http.client import HTTPConnection
10
- from typing import Callable, Iterable, Iterator, List, Optional, Set
10
+ from typing import Callable, Iterable, Iterator, List, Optional, Sequence, Set
11
11
 
12
12
  DEFAULT_PORT = 9005
13
13
  DEFAULT_TIMEOUT = 0.4
@@ -15,6 +15,10 @@ DEFAULT_TIMEOUT = 0.4
15
15
  _EXCLUDED_HOSTS = {"127.0.0.1", "localhost", "0.0.0.0"}
16
16
 
17
17
 
18
+ AddressSource = Callable[[], Iterable[str]]
19
+ ProbeHost = Callable[[str, int, float], Optional[str]]
20
+
21
+
18
22
  def _should_include_host(host: Optional[str]) -> bool:
19
23
  """Return ``True`` when *host* should be scanned for discovery."""
20
24
 
@@ -23,39 +27,85 @@ def _should_include_host(host: Optional[str]) -> bool:
23
27
  return host not in _EXCLUDED_HOSTS
24
28
 
25
29
 
26
- def _iter_local_ipv4_addresses() -> Iterator[str]:
27
- """Yield IPv4 addresses that belong to the local machine."""
30
+ def _create_udp_socket(family: int, type_: int) -> socket.socket:
31
+ """Return a UDP socket created with :func:`socket.socket`."""
28
32
 
29
- seen: Set[str] = set()
33
+ return socket.socket(family, type_)
34
+
35
+
36
+ def _iter_getaddrinfo_addresses(
37
+ *,
38
+ hostname_resolver: Callable[[], str] = socket.gethostname,
39
+ getaddrinfo: Callable[..., Sequence[Sequence[object]]] = socket.getaddrinfo,
40
+ ) -> Iterator[str]:
41
+ """Yield IPv4 addresses discovered via ``getaddrinfo``."""
30
42
 
31
43
  try:
32
- hostname = socket.gethostname()
33
- for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
34
- address = info[4][0]
35
- if address and address not in seen:
36
- seen.add(address)
37
- yield address
44
+ hostname = hostname_resolver()
45
+ except OSError:
46
+ return
47
+
48
+ try:
49
+ for info in getaddrinfo(hostname, None, family=socket.AF_INET):
50
+ try:
51
+ address = info[4][0] # type: ignore[index]
52
+ except (IndexError, TypeError):
53
+ continue
54
+ if address:
55
+ yield str(address)
38
56
  except socket.gaierror:
39
- pass
57
+ return
58
+
40
59
 
41
- for probe in ("8.8.8.8", "1.1.1.1"):
60
+ def _iter_probe_addresses(
61
+ probes: Iterable[str] = ("8.8.8.8", "1.1.1.1"),
62
+ *,
63
+ socket_factory: Callable[[int, int], socket.socket] = _create_udp_socket,
64
+ ) -> Iterator[str]:
65
+ """Yield IPv4 addresses by opening UDP sockets to well-known hosts."""
66
+
67
+ for probe in probes:
42
68
  try:
43
- with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
69
+ with closing(socket_factory(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
44
70
  sock.connect((probe, 80))
45
71
  address = sock.getsockname()[0]
46
- if address and address not in seen:
47
- seen.add(address)
72
+ if address:
48
73
  yield address
49
74
  except OSError:
50
75
  continue
51
76
 
52
77
 
53
- def _build_default_host_candidates(prefix_length: int = 24) -> List[str]:
78
+ def _iter_local_ipv4_addresses(
79
+ *, address_sources: Optional[Iterable[AddressSource]] = None
80
+ ) -> Iterator[str]:
81
+ """Yield IPv4 addresses that belong to the local machine."""
82
+
83
+ seen: Set[str] = set()
84
+ sources: Iterable[AddressSource]
85
+ if address_sources is None:
86
+ sources = (_iter_getaddrinfo_addresses, _iter_probe_addresses)
87
+ else:
88
+ sources = address_sources
89
+
90
+ for source in sources:
91
+ for address in source():
92
+ if not address:
93
+ continue
94
+ if address not in seen:
95
+ seen.add(address)
96
+ yield address
97
+
98
+
99
+ def _build_default_host_candidates(
100
+ prefix_length: int = 24,
101
+ *,
102
+ address_sources: Optional[Iterable[AddressSource]] = None,
103
+ ) -> List[str]:
54
104
  """Return a list of host candidates based on detected local networks."""
55
105
 
56
106
  hosts: Set[str] = set()
57
107
 
58
- for address in _iter_local_ipv4_addresses():
108
+ for address in _iter_local_ipv4_addresses(address_sources=address_sources):
59
109
  if _should_include_host(address):
60
110
  hosts.add(address)
61
111
  try:
@@ -104,6 +154,8 @@ def discover_servers(
104
154
  timeout: float = DEFAULT_TIMEOUT,
105
155
  hosts: Optional[Iterable[str]] = None,
106
156
  progress_callback: Optional[ProgressCallback] = None,
157
+ address_sources: Optional[Iterable[AddressSource]] = None,
158
+ probe_host: Optional[ProbeHost] = None,
107
159
  ) -> List[str]:
108
160
  """Scan *hosts* for running Talks Reducer servers on *port*.
109
161
 
@@ -111,7 +163,9 @@ def discover_servers(
111
163
  addresses are scanned. ``127.0.0.1``, ``localhost``, and ``0.0.0.0`` are
112
164
  excluded to avoid duplicating local endpoints. The optional
113
165
  *progress_callback* receives the number of scanned hosts and the total
114
- candidate count whenever discovery advances. The function returns a sorted
166
+ candidate count whenever discovery advances. Supply *address_sources* to
167
+ override the IPv4 detection logic with custom iterables, and *probe_host*
168
+ to replace the HTTP-based reachability probe. The function returns a sorted
115
169
  list of unique base URLs.
116
170
  """
117
171
 
@@ -119,7 +173,9 @@ def discover_servers(
119
173
  candidates = sorted(
120
174
  {
121
175
  host
122
- for host in _build_default_host_candidates()
176
+ for host in _build_default_host_candidates(
177
+ address_sources=address_sources
178
+ )
123
179
  if _should_include_host(host)
124
180
  }
125
181
  )
@@ -132,11 +188,11 @@ def discover_servers(
132
188
  if progress_callback is not None:
133
189
  progress_callback(0, total)
134
190
 
191
+ probe_fn: ProbeHost = _probe_host if probe_host is None else probe_host
192
+
135
193
  with ThreadPoolExecutor(max_workers=32) as executor:
136
194
  scanned = 0
137
- for url in executor.map(
138
- lambda host: _probe_host(host, port, timeout), candidates
139
- ):
195
+ for url in executor.map(lambda host: probe_fn(host, port, timeout), candidates):
140
196
  if url and url not in results:
141
197
  results.append(url)
142
198
  scanned += 1