speedy-utils 1.1.23__py3-none-any.whl → 1.1.25__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.
@@ -29,9 +29,7 @@ def dump_jsonl(list_dictionaries: list[dict], file_name: str = "output.jsonl") -
29
29
  file.write(json.dumps(dictionary, ensure_ascii=False) + "\n")
30
30
 
31
31
 
32
- def dump_json_or_pickle(
33
- obj: Any, fname: str, ensure_ascii: bool = False, indent: int = 4
34
- ) -> None:
32
+ def dump_json_or_pickle(obj: Any, fname: str, ensure_ascii: bool = False, indent: int = 4) -> None:
35
33
  """
36
34
  Dump an object to a file, supporting both JSON and pickle formats.
37
35
  """
@@ -59,6 +57,7 @@ def dump_json_or_pickle(
59
57
  if isinstance(obj, BaseModel):
60
58
  data = obj.model_dump()
61
59
  from fastcore.all import dict2obj, obj2dict
60
+
62
61
  obj2 = dict2obj(data)
63
62
  with open(fname, "wb") as f:
64
63
  pickle.dump(obj2, f)
@@ -84,7 +83,8 @@ def load_json_or_pickle(fname: str, counter=0) -> Any:
84
83
  except EOFError:
85
84
  time.sleep(1)
86
85
  if counter > 5:
87
- print("Error: Ran out of input", fname)
86
+ # Keep message concise and actionable
87
+ print(f"Corrupted cache file {fname} removed; it will be regenerated on next access")
88
88
  os.remove(fname)
89
89
  raise
90
90
  return load_json_or_pickle(fname, counter + 1)
@@ -92,8 +92,6 @@ def load_json_or_pickle(fname: str, counter=0) -> Any:
92
92
  raise ValueError(f"Error {e} while loading {fname}") from e
93
93
 
94
94
 
95
-
96
-
97
95
  try:
98
96
  import orjson # type: ignore[import-not-found] # fastest JSON parser when available
99
97
  except Exception:
@@ -113,11 +111,11 @@ def fast_load_jsonl(
113
111
  use_orjson: bool = True,
114
112
  encoding: str = "utf-8",
115
113
  errors: str = "strict",
116
- on_error: str = "raise", # 'raise' | 'warn' | 'skip'
114
+ on_error: str = "raise", # 'raise' | 'warn' | 'skip'
117
115
  skip_empty: bool = True,
118
116
  max_lines: Optional[int] = None,
119
117
  use_multiworker: bool = True,
120
- multiworker_threshold: int = 50000,
118
+ multiworker_threshold: int = 1000000,
121
119
  workers: Optional[int] = None,
122
120
  ) -> Iterable[Any]:
123
121
  """
@@ -127,7 +125,7 @@ def fast_load_jsonl(
127
125
  - Optional tqdm progress over bytes (compressed size if gz/bz2/xz/zst).
128
126
  - Auto-detects compression by extension: .gz, .bz2, .xz/.lzma, .zst/.zstd.
129
127
  - Uses orjson if available (use_orjson=True), falls back to json.
130
- - Automatically uses multi-worker processing for large files (>50k lines).
128
+ - Automatically uses multi-worker processing for large files (>100k lines).
131
129
 
132
130
  Args:
133
131
  path_or_file: Path-like or file-like object. File-like can be binary or text.
@@ -140,11 +138,12 @@ def fast_load_jsonl(
140
138
  max_lines: Stop after reading this many lines (useful for sampling).
141
139
  use_multiworker: Enable multi-worker processing for large files.
142
140
  multiworker_threshold: Line count threshold to trigger multi-worker processing.
143
- workers: Number of worker threads (defaults to CPU count).
141
+ workers: Number of worker threads (defaults to 80% of CPU count, max 8).
144
142
 
145
143
  Yields:
146
144
  Parsed Python objects per line.
147
145
  """
146
+
148
147
  def _open_auto(pth_or_f) -> IO[Any]:
149
148
  if hasattr(pth_or_f, "read"):
150
149
  # ensure binary buffer for consistent byte-length progress
@@ -206,39 +205,47 @@ def fast_load_jsonl(
206
205
 
207
206
  # Check if we should use multi-worker processing
208
207
  should_use_multiworker = (
209
- use_multiworker
208
+ use_multiworker
210
209
  and not hasattr(path_or_file, "read") # Only for file paths, not file objects
211
210
  and max_lines is None # Don't use multiworker if we're limiting lines
212
211
  )
213
-
212
+
214
213
  if should_use_multiworker:
215
214
  line_count = _count_lines_fast(cast(Union[str, os.PathLike], path_or_file))
216
215
  if line_count > multiworker_threshold:
217
216
  # Use multi-worker processing
218
217
  from ..multi_worker.thread import multi_thread
219
218
 
219
+ # Calculate optimal worker count: 80% of CPU count, capped at 8
220
+ cpu_count = os.cpu_count() or 4
221
+ default_workers = min(int(cpu_count * 0.8), 8)
222
+ num_workers = workers if workers is not None else default_workers
223
+ num_workers = max(1, num_workers) # At least 1 worker
224
+
220
225
  # Read all lines into chunks
221
226
  f = _open_auto(path_or_file)
222
227
  all_lines = list(f)
223
228
  f.close()
224
-
225
- # Split into chunks for workers
226
- num_workers = workers or os.cpu_count() or 4
227
- chunk_size = max(len(all_lines) // num_workers, 1000)
229
+
230
+ # Split into chunks - aim for ~10k-20k lines per chunk minimum
231
+ min_chunk_size = 10000
232
+ chunk_size = max(len(all_lines) // num_workers, min_chunk_size)
228
233
  chunks = []
229
234
  for i in range(0, len(all_lines), chunk_size):
230
- chunks.append(all_lines[i:i + chunk_size])
231
-
235
+ chunks.append(all_lines[i : i + chunk_size])
236
+
232
237
  # Process chunks in parallel
233
238
  if progress:
234
- print(f"Processing {line_count} lines with {num_workers} workers...")
235
-
239
+ print(f"Processing {line_count} lines with {num_workers} workers ({len(chunks)} chunks)...")
240
+
236
241
  chunk_results = multi_thread(_process_chunk, chunks, workers=num_workers, progress=progress)
237
-
242
+
238
243
  # Flatten results and yield
239
- for chunk_result in chunk_results:
240
- for obj in chunk_result:
241
- yield obj
244
+ if chunk_results:
245
+ for chunk_result in chunk_results:
246
+ if chunk_result:
247
+ for obj in chunk_result:
248
+ yield obj
242
249
  return
243
250
 
244
251
  # Single-threaded processing (original logic)
@@ -266,7 +273,11 @@ def fast_load_jsonl(
266
273
  line_no += 1
267
274
  if pbar is not None:
268
275
  # raw_line is bytes here; if not, compute byte length
269
- nbytes = len(raw_line) if isinstance(raw_line, (bytes, bytearray)) else len(str(raw_line).encode(encoding, errors))
276
+ nbytes = (
277
+ len(raw_line)
278
+ if isinstance(raw_line, (bytes, bytearray))
279
+ else len(str(raw_line).encode(encoding, errors))
280
+ )
270
281
  pbar.update(nbytes)
271
282
 
272
283
  # Normalize to bytes -> str only if needed
@@ -322,7 +333,6 @@ def fast_load_jsonl(
322
333
  pass
323
334
 
324
335
 
325
-
326
336
  def load_by_ext(fname: Union[str, list[str]], do_memoize: bool = False) -> Any:
327
337
  """
328
338
  Load data based on file extension.
@@ -3,10 +3,12 @@
3
3
  import inspect
4
4
  import os
5
5
  from collections.abc import Callable
6
- from typing import Any
6
+ from typing import Any, TypeVar
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ T = TypeVar("T")
11
+
10
12
 
11
13
  def mkdir_or_exist(dir_name: str) -> None:
12
14
  """Create a directory if it doesn't exist."""
@@ -50,10 +52,32 @@ def convert_to_builtin_python(input_data: Any) -> Any:
50
52
  raise ValueError(f"Unsupported type {type(input_data)}")
51
53
 
52
54
 
55
+ def dedup(items: list[T], key: Callable[[T], Any]) -> list[T]:
56
+ """
57
+ Deduplicate items in a list based on a key function.
58
+
59
+ Args:
60
+ items: The list of items.
61
+ key: A function that takes an item and returns a hashable key.
62
+
63
+ Returns:
64
+ A list with duplicates removed, preserving the first occurrence.
65
+ """
66
+ seen = set()
67
+ result = []
68
+ for item in items:
69
+ k = key(item)
70
+ if k not in seen:
71
+ seen.add(k)
72
+ result.append(item)
73
+ return result
74
+
75
+
53
76
  __all__ = [
54
77
  "mkdir_or_exist",
55
78
  "flatten_list",
56
79
  "get_arg_names",
57
80
  "is_notebook",
58
81
  "convert_to_builtin_python",
82
+ "dedup",
59
83
  ]
@@ -80,8 +80,10 @@
80
80
 
81
81
  import ctypes
82
82
  import os
83
+ import sys
83
84
  import threading
84
85
  import time
86
+ import traceback
85
87
  from collections.abc import Callable, Iterable, Mapping, Sequence
86
88
  from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
87
89
  from heapq import heappop, heappush
@@ -99,12 +101,42 @@ except ImportError: # pragma: no cover
99
101
  # Sensible defaults
100
102
  DEFAULT_WORKERS = (os.cpu_count() or 4) * 2
101
103
 
102
- T = TypeVar('T')
103
- R = TypeVar('R')
104
+ T = TypeVar("T")
105
+ R = TypeVar("R")
104
106
 
105
107
  SPEEDY_RUNNING_THREADS: list[threading.Thread] = [] # cooperative shutdown tracking
106
108
  _SPEEDY_THREADS_LOCK = threading.Lock()
107
109
 
110
+
111
+ class UserFunctionError(Exception):
112
+ """Exception wrapper that highlights user function errors."""
113
+
114
+ def __init__(
115
+ self,
116
+ original_exception: Exception,
117
+ func_name: str,
118
+ input_value: Any,
119
+ user_traceback: list[traceback.FrameSummary],
120
+ ) -> None:
121
+ self.original_exception = original_exception
122
+ self.func_name = func_name
123
+ self.input_value = input_value
124
+ self.user_traceback = user_traceback
125
+
126
+ # Create a focused error message
127
+ tb_str = "".join(traceback.format_list(user_traceback))
128
+ msg = (
129
+ f'\nError in function "{func_name}" with input: {input_value!r}\n'
130
+ f"\nUser code traceback:\n{tb_str}"
131
+ f"{type(original_exception).__name__}: {original_exception}"
132
+ )
133
+ super().__init__(msg)
134
+
135
+ def __str__(self) -> str:
136
+ # Return focused error without infrastructure frames
137
+ return super().__str__()
138
+
139
+
108
140
  _PY_SET_ASYNC_EXC = ctypes.pythonapi.PyThreadState_SetAsyncExc
109
141
  try:
110
142
  _PY_SET_ASYNC_EXC.argtypes = (ctypes.c_ulong, ctypes.py_object) # type: ignore[attr-defined]
@@ -133,7 +165,7 @@ def _track_threads(threads: Iterable[threading.Thread]) -> None:
133
165
 
134
166
 
135
167
  def _track_executor_threads(pool: ThreadPoolExecutor) -> None:
136
- thread_set = getattr(pool, '_threads', None)
168
+ thread_set = getattr(pool, "_threads", None)
137
169
  if not thread_set:
138
170
  return
139
171
  _track_threads(tuple(thread_set))
@@ -152,7 +184,48 @@ def _worker(
152
184
  fixed_kwargs: Mapping[str, Any],
153
185
  ) -> R:
154
186
  """Execute the function with an item and fixed kwargs."""
155
- return func(item, **fixed_kwargs)
187
+ # Validate func is callable before attempting to call it
188
+ if not callable(func):
189
+ func_type = type(func).__name__
190
+ raise TypeError(
191
+ f"\nmulti_thread: func parameter must be callable, "
192
+ f"got {func_type}: {func!r}\n"
193
+ f"Hint: Did you accidentally pass a {func_type} instead of a function?"
194
+ )
195
+
196
+ try:
197
+ return func(item, **fixed_kwargs)
198
+ except Exception as exc:
199
+ # Extract user code traceback (filter out infrastructure)
200
+ exc_tb = sys.exc_info()[2]
201
+
202
+ if exc_tb is not None:
203
+ tb_list = traceback.extract_tb(exc_tb)
204
+
205
+ # Filter to keep only user code frames
206
+ user_frames = []
207
+ skip_patterns = [
208
+ "multi_worker/thread.py",
209
+ "concurrent/futures/",
210
+ "threading.py",
211
+ ]
212
+
213
+ for frame in tb_list:
214
+ if not any(pattern in frame.filename for pattern in skip_patterns):
215
+ user_frames.append(frame)
216
+
217
+ # If we have user frames, wrap in our custom exception
218
+ if user_frames:
219
+ func_name = getattr(func, "__name__", repr(func))
220
+ raise UserFunctionError(
221
+ exc,
222
+ func_name,
223
+ item,
224
+ user_frames,
225
+ ) from exc
226
+
227
+ # Fallback: re-raise original if we couldn't extract frames
228
+ raise
156
229
 
157
230
 
158
231
  def _run_batch(
@@ -164,14 +237,14 @@ def _run_batch(
164
237
 
165
238
 
166
239
  def _attach_metadata(fut: Future[Any], idx: int, logical_size: int) -> None:
167
- setattr(fut, '_speedy_idx', idx)
168
- setattr(fut, '_speedy_size', logical_size)
240
+ setattr(fut, "_speedy_idx", idx)
241
+ setattr(fut, "_speedy_size", logical_size)
169
242
 
170
243
 
171
244
  def _future_meta(fut: Future[Any]) -> tuple[int, int]:
172
245
  return (
173
- getattr(fut, '_speedy_idx'),
174
- getattr(fut, '_speedy_size'),
246
+ getattr(fut, "_speedy_idx"),
247
+ getattr(fut, "_speedy_size"),
175
248
  )
176
249
 
177
250
 
@@ -219,7 +292,7 @@ def _resolve_worker_count(workers: int | None) -> int:
219
292
  if workers is None:
220
293
  return DEFAULT_WORKERS
221
294
  if workers <= 0:
222
- raise ValueError('workers must be a positive integer')
295
+ raise ValueError("workers must be a positive integer")
223
296
  return workers
224
297
 
225
298
 
@@ -227,18 +300,18 @@ def _normalize_batch_result(result: Any, logical_size: int) -> list[Any]:
227
300
  if logical_size == 1:
228
301
  return [result]
229
302
  if result is None:
230
- raise ValueError('batched callable returned None for a batch result')
303
+ raise ValueError("batched callable returned None for a batch result")
231
304
  if isinstance(result, (str, bytes, bytearray)):
232
- raise TypeError('batched callable must not return str/bytes when batching')
305
+ raise TypeError("batched callable must not return str/bytes when batching")
233
306
  if isinstance(result, Sequence):
234
307
  out = list(result)
235
308
  elif isinstance(result, Iterable):
236
309
  out = list(result)
237
310
  else:
238
- raise TypeError('batched callable must return an iterable of results')
311
+ raise TypeError("batched callable must return an iterable of results")
239
312
  if len(out) != logical_size:
240
313
  raise ValueError(
241
- f'batched callable returned {len(out)} items, expected {logical_size}',
314
+ f"batched callable returned {len(out)} items, expected {logical_size}",
242
315
  )
243
316
  return out
244
317
 
@@ -325,7 +398,7 @@ def multi_thread(
325
398
  results: list[R | None] = []
326
399
 
327
400
  for proc_idx, chunk in enumerate(chunks):
328
- with tempfile.NamedTemporaryFile(delete=False, suffix='multi_thread.pkl') as fh:
401
+ with tempfile.NamedTemporaryFile(delete=False, suffix="multi_thread.pkl") as fh:
329
402
  file_pkl = fh.name
330
403
  assert isinstance(in_process_multi_thread, Callable)
331
404
  proc = in_process_multi_thread(
@@ -347,28 +420,28 @@ def multi_thread(
347
420
 
348
421
  for proc, file_pkl in procs:
349
422
  proc.join()
350
- logger.info('process finished: %s', proc)
423
+ logger.info("process finished: %s", proc)
351
424
  try:
352
425
  results.extend(load_by_ext(file_pkl))
353
426
  finally:
354
427
  try:
355
428
  os.unlink(file_pkl)
356
429
  except OSError as exc: # pragma: no cover - best effort cleanup
357
- logger.warning('failed to remove temp file %s: %s', file_pkl, exc)
430
+ logger.warning("failed to remove temp file %s: %s", file_pkl, exc)
358
431
  return results
359
432
 
360
433
  try:
361
434
  import pandas as pd
362
435
 
363
436
  if isinstance(inputs, pd.DataFrame):
364
- inputs = cast(Iterable[T], inputs.to_dict(orient='records'))
437
+ inputs = cast(Iterable[T], inputs.to_dict(orient="records"))
365
438
  except ImportError: # pragma: no cover - optional dependency
366
439
  pass
367
440
 
368
441
  if batch <= 0:
369
- raise ValueError('batch must be a positive integer')
442
+ raise ValueError("batch must be a positive integer")
370
443
  if prefetch_factor <= 0:
371
- raise ValueError('prefetch_factor must be a positive integer')
444
+ raise ValueError("prefetch_factor must be a positive integer")
372
445
 
373
446
  workers_val = _resolve_worker_count(workers)
374
447
  progress_update = max(progress_update, 1)
@@ -390,20 +463,12 @@ def multi_thread(
390
463
 
391
464
  bar = None
392
465
  last_bar_update = 0
393
- if (
394
- progress
395
- and tqdm is not None
396
- and logical_total is not None
397
- and logical_total > 0
398
- ):
466
+ if progress and tqdm is not None and logical_total is not None and logical_total > 0:
399
467
  bar = tqdm(
400
468
  total=logical_total,
401
469
  ncols=128,
402
- colour='green',
403
- bar_format=(
404
- '{l_bar}{bar}| {n_fmt}/{total_fmt}'
405
- ' [{elapsed}<{remaining}, {rate_fmt}{postfix}]'
406
- ),
470
+ colour="green",
471
+ bar_format=("{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]"),
407
472
  )
408
473
 
409
474
  deadline = time.monotonic() + timeout if timeout is not None else None
@@ -417,11 +482,12 @@ def multi_thread(
417
482
  inflight: set[Future[Any]] = set()
418
483
  pool = ThreadPoolExecutor(
419
484
  max_workers=workers_val,
420
- thread_name_prefix='speedy-thread',
485
+ thread_name_prefix="speedy-thread",
421
486
  )
422
- shutdown_kwargs: dict[str, Any] = {'wait': True}
487
+ shutdown_kwargs: dict[str, Any] = {"wait": True}
423
488
 
424
489
  try:
490
+
425
491
  def submit_arg(arg: Any) -> None:
426
492
  nonlocal next_logical_idx
427
493
  if batch > 1:
@@ -451,7 +517,7 @@ def multi_thread(
451
517
  if remaining <= 0:
452
518
  _cancel_futures(inflight)
453
519
  raise TimeoutError(
454
- f'multi_thread timed out after {timeout} seconds',
520
+ f"multi_thread timed out after {timeout} seconds",
455
521
  )
456
522
  wait_timeout = max(remaining, 0.0)
457
523
 
@@ -464,7 +530,7 @@ def multi_thread(
464
530
  if not done:
465
531
  _cancel_futures(inflight)
466
532
  raise TimeoutError(
467
- f'multi_thread timed out after {timeout} seconds',
533
+ f"multi_thread timed out after {timeout} seconds",
468
534
  )
469
535
 
470
536
  for fut in done:
@@ -472,11 +538,37 @@ def multi_thread(
472
538
  idx, logical_size = _future_meta(fut)
473
539
  try:
474
540
  result = fut.result()
541
+ except UserFunctionError as exc:
542
+ # User function error - already has clean traceback
543
+ logger.error(str(exc))
544
+
545
+ if stop_on_error:
546
+ _cancel_futures(inflight)
547
+ # Create a clean exception without infrastructure frames
548
+ # by re-creating the traceback
549
+ orig_exc = exc.original_exception
550
+
551
+ # Build new traceback from user frames only
552
+ tb_str = "".join(traceback.format_list(exc.user_traceback))
553
+ clean_msg = (
554
+ f'\nError in "{exc.func_name}" '
555
+ f"with input: {exc.input_value!r}\n\n{tb_str}"
556
+ f"{type(orig_exc).__name__}: {orig_exc}"
557
+ )
558
+
559
+ # Raise a new instance of the original exception type
560
+ # with our clean message
561
+ new_exc = type(orig_exc)(clean_msg)
562
+ # Suppress the "from" chain to avoid showing infrastructure
563
+ raise new_exc from None
564
+
565
+ out_items = [None] * logical_size
475
566
  except Exception as exc:
567
+ # Other errors (infrastructure, batching, etc.)
476
568
  if stop_on_error:
477
569
  _cancel_futures(inflight)
478
570
  raise
479
- logger.exception('multi_thread task failed', exc_info=exc)
571
+ logger.exception("multi_thread task failed", exc_info=exc)
480
572
  out_items = [None] * logical_size
481
573
  else:
482
574
  try:
@@ -484,7 +576,7 @@ def multi_thread(
484
576
  except Exception as exc:
485
577
  _cancel_futures(inflight)
486
578
  raise RuntimeError(
487
- 'batched callable returned an unexpected shape',
579
+ "batched callable returned an unexpected shape",
488
580
  ) from exc
489
581
 
490
582
  collector.add(idx, out_items)
@@ -496,14 +588,10 @@ def multi_thread(
496
588
  bar.update(delta)
497
589
  last_bar_update = completed_items
498
590
  submitted = next_logical_idx
499
- pending = (
500
- max(logical_total - submitted, 0)
501
- if logical_total is not None
502
- else '-'
503
- )
591
+ pending = max(logical_total - submitted, 0) if logical_total is not None else "-"
504
592
  postfix = {
505
- 'processing': min(len(inflight), workers_val),
506
- 'pending': pending,
593
+ "processing": min(len(inflight), workers_val),
594
+ "pending": pending,
507
595
  }
508
596
  bar.set_postfix(postfix)
509
597
 
@@ -516,7 +604,7 @@ def multi_thread(
516
604
  results = collector.finalize()
517
605
 
518
606
  except KeyboardInterrupt:
519
- shutdown_kwargs = {'wait': False, 'cancel_futures': True}
607
+ shutdown_kwargs = {"wait": False, "cancel_futures": True}
520
608
  _cancel_futures(inflight)
521
609
  kill_all_thread(SystemExit)
522
610
  raise KeyboardInterrupt() from None
@@ -524,29 +612,27 @@ def multi_thread(
524
612
  try:
525
613
  pool.shutdown(**shutdown_kwargs)
526
614
  except TypeError: # pragma: no cover - Python <3.9 fallback
527
- pool.shutdown(shutdown_kwargs.get('wait', True))
615
+ pool.shutdown(shutdown_kwargs.get("wait", True))
528
616
  if bar:
529
617
  delta = completed_items - last_bar_update
530
618
  if delta > 0:
531
619
  bar.update(delta)
532
620
  bar.close()
533
621
 
534
- results = collector.finalize() if 'results' not in locals() else results
622
+ results = collector.finalize() if "results" not in locals() else results
535
623
  if store_output_pkl_file:
536
624
  dump_json_or_pickle(results, store_output_pkl_file)
537
625
  _prune_dead_threads()
538
626
  return results
539
627
 
540
628
 
541
- def multi_thread_standard(
542
- fn: Callable[[T], R], items: Iterable[T], workers: int = 4
543
- ) -> list[R]:
629
+ def multi_thread_standard(fn: Callable[[T], R], items: Iterable[T], workers: int = 4) -> list[R]:
544
630
  """Execute ``fn`` across ``items`` while preserving submission order."""
545
631
 
546
632
  workers_val = _resolve_worker_count(workers)
547
633
  with ThreadPoolExecutor(
548
634
  max_workers=workers_val,
549
- thread_name_prefix='speedy-thread',
635
+ thread_name_prefix="speedy-thread",
550
636
  ) as executor:
551
637
  futures: list[Future[R]] = []
552
638
  for item in items:
@@ -561,13 +647,13 @@ def _async_raise(thread_id: int, exc_type: type[BaseException]) -> bool:
561
647
  if thread_id <= 0:
562
648
  return False
563
649
  if not issubclass(exc_type, BaseException):
564
- raise TypeError('exc_type must derive from BaseException')
650
+ raise TypeError("exc_type must derive from BaseException")
565
651
  res = _PY_SET_ASYNC_EXC(ctypes.c_ulong(thread_id), ctypes.py_object(exc_type))
566
652
  if res == 0:
567
653
  return False
568
654
  if res > 1: # pragma: no cover - defensive branch
569
655
  _PY_SET_ASYNC_EXC(ctypes.c_ulong(thread_id), None)
570
- raise SystemError('PyThreadState_SetAsyncExc failed')
656
+ raise SystemError("PyThreadState_SetAsyncExc failed")
571
657
  return True
572
658
 
573
659
 
@@ -596,16 +682,17 @@ def kill_all_thread(exc_type: type[BaseException] = SystemExit, join_timeout: fl
596
682
  terminated += 1
597
683
  thread.join(timeout=join_timeout)
598
684
  else:
599
- logger.warning('Unable to signal thread %s', thread.name)
685
+ logger.warning("Unable to signal thread %s", thread.name)
600
686
  except Exception as exc: # pragma: no cover - defensive
601
- logger.error('Failed to stop thread %s: %s', thread.name, exc)
687
+ logger.error("Failed to stop thread %s: %s", thread.name, exc)
602
688
  _prune_dead_threads()
603
689
  return terminated
604
690
 
605
691
 
606
692
  __all__ = [
607
- 'SPEEDY_RUNNING_THREADS',
608
- 'multi_thread',
609
- 'multi_thread_standard',
610
- 'kill_all_thread',
693
+ "SPEEDY_RUNNING_THREADS",
694
+ "UserFunctionError",
695
+ "multi_thread",
696
+ "multi_thread_standard",
697
+ "kill_all_thread",
611
698
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: speedy-utils
3
- Version: 1.1.23
3
+ Version: 1.1.25
4
4
  Summary: Fast and easy-to-use package for data science
5
5
  Project-URL: Homepage, https://github.com/anhvth/speedy
6
6
  Project-URL: Repository, https://github.com/anhvth/speedy