|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import copyreg |
| 6 | +import io |
5 | 7 | import math |
6 | | -from collections.abc import Generator, Iterable |
| 8 | +import pickle |
| 9 | +from collections.abc import Callable, Generator, Iterable, Iterator |
| 10 | +from contextvars import ContextVar |
7 | 11 | from types import ModuleType |
8 | | -from typing import TYPE_CHECKING, cast |
| 12 | +from typing import TYPE_CHECKING, Any, TypeVar, cast |
9 | 13 |
|
10 | 14 | from . import _compat |
11 | 15 | from ._compat import ( |
|
22 | 26 | # TODO import from typing (requires Python >=3.13) |
23 | 27 | from typing_extensions import TypeIs |
24 | 28 |
|
| 29 | +T = TypeVar("T") |
| 30 | + |
25 | 31 |
|
26 | 32 | __all__ = [ |
27 | 33 | "asarrays", |
|
31 | 37 | "is_python_scalar", |
32 | 38 | "mean", |
33 | 39 | "meta_namespace", |
| 40 | + "pickle_without", |
| 41 | + "unpickle_without", |
34 | 42 | ] |
35 | 43 |
|
36 | 44 |
|
@@ -306,3 +314,127 @@ def capabilities(xp: ModuleType) -> dict[str, int]: |
306 | 314 | out["boolean indexing"] = True |
307 | 315 | out["data-dependent shapes"] = True |
308 | 316 | return out |
| 317 | + |
| 318 | + |
| 319 | +# Helper of ``extract_objects`` and ``repack_objects`` |
| 320 | +_repacking_objects: ContextVar[Iterator[object]] = ContextVar("_repacking_objects") |
| 321 | + |
| 322 | + |
| 323 | +def _expand() -> object: # numpydoc ignore=RT01 |
| 324 | + """ |
| 325 | + Helper of ``extract_objects`` and ``repack_objects``. |
| 326 | +
|
| 327 | + Inverse of the reducer function. |
| 328 | +
|
| 329 | + Notes |
| 330 | + ----- |
| 331 | + This function must be global in order to be picklable. |
| 332 | + """ |
| 333 | + try: |
| 334 | + return next(_repacking_objects.get()) |
| 335 | + except StopIteration: |
| 336 | + msg = "Not enough objects to repack" |
| 337 | + raise ValueError(msg) |
| 338 | + |
| 339 | + |
| 340 | +def pickle_without(obj: object, *classes: type[T]) -> tuple[bytes, list[T]]: |
| 341 | + """ |
| 342 | + Variant of ``pickle.dumps`` that extracts inner objects. |
| 343 | +
|
| 344 | + Conceptually, this is similar to passing the ``buffer_callback`` argument to |
| 345 | + ``pickle.dumps``, but instead of extracting buffers it extracts entire objects. |
| 346 | +
|
| 347 | + Parameters |
| 348 | + ---------- |
| 349 | + obj : object |
| 350 | + The object to pickle. |
| 351 | + *classes : type |
| 352 | + One or more classes to extract from the object. |
| 353 | + The instances of these classes inside ``obj`` will not be pickled. |
| 354 | +
|
| 355 | + Returns |
| 356 | + ------- |
| 357 | + bytes |
| 358 | + The pickled object. Must be unpickled with :func:`unpickle_without`. |
| 359 | + list |
| 360 | + All instances of ``classes`` found inside ``obj`` (not pickled). |
| 361 | +
|
| 362 | + See Also |
| 363 | + -------- |
| 364 | + pickle.dumps : Standard pickle function. |
| 365 | + unpickle_without : Reverse function. |
| 366 | +
|
| 367 | + Examples |
| 368 | + -------- |
| 369 | + >>> class A: |
| 370 | + ... def __repr__(self): |
| 371 | + ... return "<A>" |
| 372 | + ... def __reduce__(self): |
| 373 | + ... assert False, "Not serializable" |
| 374 | + >>> obj = {1: A(), 2: [A(), A()]} # Any serializable object |
| 375 | + >>> pik, extracted = pickle_without(obj, A) |
| 376 | + >>> extracted |
| 377 | + [<A>, <A>, <A>] |
| 378 | + >>> unpickle_without(pik, extracted) |
| 379 | + {1: <A>, 2: [<A>, <A>]} |
| 380 | +
|
| 381 | + This can be also used to hot-swap inner objects; the only constraint is that |
| 382 | + the number of objects in and out must be the same: |
| 383 | +
|
| 384 | + >>> class B: |
| 385 | + ... def __repr__(self): return "<B>" |
| 386 | + >>> unpickle_without(pik, [B(), B(), B()]) |
| 387 | + {1: <B>, 2: [<B>, <B>]} |
| 388 | + """ |
| 389 | + extracted = [] |
| 390 | + |
| 391 | + def reduce(x: T) -> tuple[Callable[[], object], tuple[()]]: # numpydoc ignore=GL08 |
| 392 | + extracted.append(x) |
| 393 | + return _expand, () |
| 394 | + |
| 395 | + f = io.BytesIO() |
| 396 | + p = pickle.Pickler(f) |
| 397 | + |
| 398 | + # Override the reducer for the given classes and all their |
| 399 | + # subclasses (recursively). |
| 400 | + p.dispatch_table = copyreg.dispatch_table.copy() |
| 401 | + subclasses = list(classes) |
| 402 | + while subclasses: |
| 403 | + cls = subclasses.pop() |
| 404 | + p.dispatch_table[cls] = reduce |
| 405 | + subclasses.extend(cls.__subclasses__()) |
| 406 | + |
| 407 | + p.dump(obj) |
| 408 | + |
| 409 | + return f.getvalue(), extracted |
| 410 | + |
| 411 | + |
| 412 | +def unpickle_without(pik: bytes, objects: Iterable[object], /) -> Any: # type: ignore[explicit-any] |
| 413 | + """ |
| 414 | + Variant of ``pickle.loads``, reverse of ``pickle_without``. |
| 415 | +
|
| 416 | + Parameters |
| 417 | + ---------- |
| 418 | + pik : bytes |
| 419 | + The pickled object generated by ``pickle_without``. |
| 420 | + objects : Iterable |
| 421 | + The objects to be reinserted into the unpickled object. |
| 422 | + Must be the at least the same number of elements as the ones extracted by |
| 423 | + ``pickle_without``, but does not need to be the same objects or even the |
| 424 | + same types of objects. Excess objects, if any, won't be inserted. |
| 425 | +
|
| 426 | + Returns |
| 427 | + ------- |
| 428 | + object |
| 429 | + The unpickled object, with the objects in ``objects`` inserted back into it. |
| 430 | +
|
| 431 | + See Also |
| 432 | + -------- |
| 433 | + pickle_without : Serializing function. |
| 434 | + pickle.loads : Standard unpickle function. |
| 435 | + """ |
| 436 | + tok = _repacking_objects.set(iter(objects)) |
| 437 | + try: |
| 438 | + return pickle.loads(pik) |
| 439 | + finally: |
| 440 | + _repacking_objects.reset(tok) |
0 commit comments