|
58 | 58 | "format_period", |
59 | 59 | "slugify", |
60 | 60 | "alternate_parse_settings", |
| 61 | + "exception_to_dict", |
| 62 | + "rebuild_exception", |
| 63 | + "ReprException", |
61 | 64 | "get_all_players", |
62 | 65 | "is_ucid", |
63 | 66 | "get_presets", |
|
75 | 78 | "deep_merge", |
76 | 79 | "hash_password", |
77 | 80 | "run_parallel_nofail", |
| 81 | + "safe_set_result", |
78 | 82 | "evaluate", |
79 | 83 | "for_each", |
80 | 84 | "YAMLError", |
@@ -331,6 +335,58 @@ def parse(value: str) -> int | str | bool: |
331 | 335 | return settings |
332 | 336 |
|
333 | 337 |
|
| 338 | +def exception_to_dict(e: BaseException) -> dict[str, Any]: |
| 339 | + """Return a plain‑dict representation of any exception.""" |
| 340 | + exc_dict = { |
| 341 | + 'class': f'{e.__class__.__module__}.{e.__class__.__name__}', |
| 342 | + 'message': str(e), |
| 343 | + 'traceback': traceback.format_exception_only(type(e), e), |
| 344 | + } |
| 345 | + |
| 346 | + # Serialise args (convert each to a string / repr) |
| 347 | + exc_dict['args'] = [repr(a) for a in e.args] |
| 348 | + |
| 349 | + # Pull out useful OSError / socket attributes |
| 350 | + # (only those that are JSON‑friendly) |
| 351 | + for key in ('errno', 'strerror', 'filename', 'filename2'): |
| 352 | + if hasattr(e, key): |
| 353 | + exc_dict[key] = getattr(e, key) |
| 354 | + |
| 355 | + # If the exception has a kwargs dict (rare), sanitize it |
| 356 | + kwargs = getattr(e, 'kwargs', None) |
| 357 | + if isinstance(kwargs, dict): |
| 358 | + exc_dict['kwargs'] = {k: repr(v) for k, v in kwargs.items()} |
| 359 | + |
| 360 | + return exc_dict |
| 361 | + |
| 362 | + |
| 363 | +class ReprException(Exception): |
| 364 | + """Wrapper that keeps the original payload if we can’t rebuild it.""" |
| 365 | + def __init__(self, payload: dict[str, Any]): |
| 366 | + self.payload = payload |
| 367 | + super().__init__(f'Unable to reconstruct exception from {payload!r}') |
| 368 | + |
| 369 | + |
| 370 | +def rebuild_exception(payload: dict[str, Any]) -> BaseException: |
| 371 | + """ |
| 372 | + Recreate a BaseException from the serialized payload. |
| 373 | + If the payload cannot be used to instantiate the original type, |
| 374 | + we return a lightweight wrapper that stores the payload. |
| 375 | + """ |
| 376 | + cls = str_to_class(payload['class']) |
| 377 | + if not cls: |
| 378 | + return ReprException(payload) |
| 379 | + |
| 380 | + args = tuple(payload.get('args', ())) # ensures a tuple |
| 381 | + kwargs = dict(payload.get('kwargs', {})) # ensures a dict |
| 382 | + |
| 383 | + try: |
| 384 | + return cls(*args, **kwargs) |
| 385 | + except Exception: |
| 386 | + # Constructor raised an unexpected error – fall back. |
| 387 | + return ReprException(payload) |
| 388 | + |
| 389 | + |
334 | 390 | def get_all_players(self, linked: bool | None = None, watchlist: bool | None = None, |
335 | 391 | vip: bool | None = None) -> list[tuple[str, str]]: |
336 | 392 | """ |
@@ -945,15 +1001,39 @@ def tree_delete(d: dict, key: str, debug: bool | None = False): |
945 | 1001 | curr_element.pop(int(keys[-1])) |
946 | 1002 |
|
947 | 1003 |
|
948 | | -def deep_merge(dict1, dict2): |
949 | | - result = dict(dict1) # Create a shallow copy of dict1 |
950 | | - for key, value in dict2.items(): |
951 | | - if key in result and isinstance(result[key], Mapping) and isinstance(value, Mapping): |
952 | | - # Recursively merge dictionaries |
| 1004 | +def deep_merge(d1: Mapping[str, Any], d2: Mapping[str, Any]) -> Mapping[str, Any]: |
| 1005 | + """ |
| 1006 | + Merge two dictionaries recursively. Non‑mapping values are overwritten. |
| 1007 | +
|
| 1008 | + Parameters |
| 1009 | + ---------- |
| 1010 | + d1, d2 : Mapping |
| 1011 | + Input mappings to merge. They are *not* modified. |
| 1012 | +
|
| 1013 | + Returns |
| 1014 | + ------- |
| 1015 | + dict |
| 1016 | + A new dictionary containing the deep merge of `d1` and `d2`. |
| 1017 | + """ |
| 1018 | + if not isinstance(d1, Mapping): |
| 1019 | + raise TypeError(f"d1 must be a Mapping, got {type(d1).__name__}") |
| 1020 | + if not isinstance(d2, Mapping): |
| 1021 | + raise TypeError(f"d2 must be a Mapping, got {type(d2).__name__}") |
| 1022 | + |
| 1023 | + result: dict = dict(d1) # shallow copy of d1 |
| 1024 | + |
| 1025 | + for key, value in d2.items(): |
| 1026 | + # If both sides are mappings, merge recursively |
| 1027 | + if ( |
| 1028 | + key in result |
| 1029 | + and isinstance(result[key], Mapping) |
| 1030 | + and isinstance(value, Mapping) |
| 1031 | + ): |
953 | 1032 | result[key] = deep_merge(result[key], value) |
954 | 1033 | else: |
955 | | - # Overwrite or add the new key-value pair |
| 1034 | + # Overwrite or add the new key/value pair |
956 | 1035 | result[key] = value |
| 1036 | + |
957 | 1037 | return result |
958 | 1038 |
|
959 | 1039 |
|
@@ -981,6 +1061,11 @@ async def run_parallel_nofail(*tasks): |
981 | 1061 | await asyncio.gather(*tasks, return_exceptions=True) |
982 | 1062 |
|
983 | 1063 |
|
| 1064 | +def safe_set_result(fut: asyncio.Future, payload: dict) -> None: |
| 1065 | + if not fut.done(): |
| 1066 | + fut.set_result(payload) |
| 1067 | + |
| 1068 | + |
984 | 1069 | def evaluate(value: str | int | float | bool | list | dict, **kwargs) -> str | int | float | bool | list | dict: |
985 | 1070 | """ |
986 | 1071 | Evaluate the given value, replacing placeholders with keyword arguments if necessary. |
|
0 commit comments