Skip to content

Commit 6bf20c8

Browse files
authored
Merge pull request #1450 from moreati/release-0.3.41
Release 0.3.41
2 parents 1a172fc + ef3cade commit 6bf20c8

File tree

8 files changed

+211
-97
lines changed

8 files changed

+211
-97
lines changed

docs/changelog.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ To avail of fixes in an unreleased version, please download a ZIP file
1818
`directly from GitHub <https://github.com/mitogen-hq/mitogen/>`_.
1919

2020

21+
v0.3.41 (2026-02-10)
22+
--------------------
23+
24+
* :gh:issue:`1441` :mod:`mitogen`: Consolidate :mod:`pickle` imports and
25+
backward compatibility handling.
26+
* :gh:issue:`126` :mod:`mitogen`: Switch :class:`mitogen.core.Unpickler`
27+
to default deny policy when handling :data:`pickle.GLOBAL` opcode.
28+
* :gh:issue:`1430` :mod:`mitogen`: Pickle top-level ``bytes`` objects
29+
ourself on Python 3.x, to avoid ``_codecs.encode()`` call injected by
30+
:class:`pickle.Pickler`
31+
* :gh:issue:`1430` :mod:`mitogen`: Remove caching of result of
32+
:meth:`mitogen.core.Message.unpickle`
33+
* :gh:issue:`1430` :mod:`mitogen`: Allow mutiple pickle streams in a single
34+
:class:`mitogen.core.Message`, add :meth:`mitogen.core.Message.unpickle_iter`
35+
* :gh:issue:`1430` :mod:`mitogen`: Speed up :class:`mitogen.core.ResourceReader`
36+
using 2 pickle streams in :data:`mitogen.core.LOAD_RESOURCE` messages
37+
* :gh:issue:`1430` :mod:`mitogen`: Default to :func:`mitogen.core.find_deny`
38+
in :meth:`mitogen.core.Message.unpickle_iter`
39+
40+
2141
v0.3.40 (2026-02-04)
2242
--------------------
2343

mitogen/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636

3737
#: Library version as a tuple.
38-
__version__ = (0, 3, 40)
38+
__version__ = (0, 3, 41)
3939

4040

4141
#: This is :data:`False` in slave contexts. Previously it was used to prevent

mitogen/core.py

Lines changed: 82 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ def _path_importer_cache(cls, path):
7070
import itertools
7171
import logging
7272
import os
73-
import pickle as py_pickle
7473
import pstats
7574
import pty
7675
import signal
@@ -113,12 +112,57 @@ def set_blocking(fd, blocking):
113112
now = time.time
114113

115114
if sys.version_info >= (3, 0):
115+
from pickle import PicklingError, Unpickler as _Unpickler, UnpicklingError
116+
def find_deny(module, name):
117+
raise UnpicklingError('Denied: %s.%s' % (module, name))
118+
class Unpickler(_Unpickler):
119+
def __init__(self, file, find_class=find_deny):
120+
self.find_class = find_class
121+
super().__init__(file, encoding='bytes')
122+
else:
123+
from cPickle import PicklingError, Unpickler as _Unpickler, UnpicklingError
124+
def find_deny(module, name):
125+
raise UnpicklingError('Denied: %s.%s' % (module, name))
126+
def Unpickler(file, find_class=find_deny):
127+
unpickler = _Unpickler(file)
128+
unpickler.find_global = find_class
129+
return unpickler
130+
131+
if sys.version_info >= (3, 0):
132+
from pickle import Pickler as _Pickler
133+
class Pickler(_Pickler):
134+
def __init__(self, file, protocol):
135+
self._file = file
136+
self._protocol = protocol
137+
super().__init__(file, protocol)
138+
def dump(self, obj):
139+
if self._protocol == 2 and type(obj) == bytes:
140+
self._file.write(struct.pack('<BBBL', 128, 2, 84, len(obj)))
141+
self._file.write(obj)
142+
self._file.write(struct.pack('<B', 46))
143+
else:
144+
super().dump(obj)
116145
str_partition, str_rpartition = str.partition, str.rpartition
117146
bytes_partition = bytes.partition
118147
elif sys.version_info >= (2, 5):
148+
from cPickle import Pickler
119149
str_partition, str_rpartition = unicode.partition, unicode.rpartition
120150
bytes_partition = str.partition
121151
else:
152+
import pickle
153+
class Pickler(pickle.Pickler):
154+
def save_exc_inst(self, obj):
155+
if isinstance(obj, CallError):
156+
func, args = obj.__reduce__()
157+
self.save(func)
158+
self.save(args)
159+
self.write(pickle.REDUCE)
160+
else:
161+
pickle.Pickler.save_inst(self, obj)
162+
163+
dispatch = pickle.Pickler.dispatch.copy()
164+
dispatch[pickle.InstanceType] = save_exc_inst
165+
122166
def _part(s, sep, find):
123167
"(str|unicode).(partition|rpartition) polyfill for Python 2.4"
124168
idx = find(sep)
@@ -217,7 +261,6 @@ def any(it):
217261
PY24 = sys.version_info < (2, 5)
218262
PY3 = sys.version_info > (3,)
219263
if sys.version_info >= (3, 0):
220-
import pickle
221264
import _thread as thread
222265
from io import BytesIO
223266
b = str.encode
@@ -229,7 +272,6 @@ def any(it):
229272
iteritems, iterkeys, itervalues = dict.items, dict.keys, dict.values
230273
range = range
231274
else:
232-
import cPickle as pickle
233275
import thread
234276
from cStringIO import StringIO as BytesIO
235277
b = str
@@ -746,54 +788,6 @@ def iter_split(buf, delim, func):
746788
return buf[start:], cont
747789

748790

749-
class Py24Pickler(py_pickle.Pickler):
750-
"""
751-
Exceptions were classic classes until Python 2.5. Sadly for 2.4, cPickle
752-
offers little control over how a classic instance is pickled. Therefore 2.4
753-
uses a pure-Python pickler, so CallError can be made to look as it does on
754-
newer Pythons.
755-
756-
This mess will go away once proper serialization exists.
757-
"""
758-
@classmethod
759-
def dumps(cls, obj, protocol):
760-
bio = BytesIO()
761-
self = cls(bio, protocol=protocol)
762-
self.dump(obj)
763-
return bio.getvalue()
764-
765-
def save_exc_inst(self, obj):
766-
if isinstance(obj, CallError):
767-
func, args = obj.__reduce__()
768-
self.save(func)
769-
self.save(args)
770-
self.write(py_pickle.REDUCE)
771-
else:
772-
py_pickle.Pickler.save_inst(self, obj)
773-
774-
if sys.version_info < (2, 5):
775-
dispatch = py_pickle.Pickler.dispatch.copy()
776-
dispatch[py_pickle.InstanceType] = save_exc_inst
777-
778-
779-
if sys.version_info >= (3, 0):
780-
# In 3.x Unpickler is a class exposing find_class as an overridable, but it
781-
# cannot be overridden without subclassing.
782-
class _Unpickler(pickle.Unpickler):
783-
def find_class(self, module, func):
784-
return self.find_global(module, func)
785-
pickle__dumps = pickle.dumps
786-
elif sys.version_info < (2, 5):
787-
# On Python 2.4, we must use a pure-Python pickler.
788-
pickle__dumps = Py24Pickler.dumps
789-
_Unpickler = pickle.Unpickler
790-
else:
791-
pickle__dumps = pickle.dumps
792-
# In 2.x Unpickler is a function exposing a writeable find_global
793-
# attribute.
794-
_Unpickler = pickle.Unpickler
795-
796-
797791
class Message(object):
798792
"""
799793
Messages are the fundamental unit of communication, comprising fields from
@@ -835,8 +829,6 @@ class Message(object):
835829
#: :ref:`standard-handles` should explicitly declare an encoding.
836830
enc = ENC_MGC
837831

838-
_unpickled = object()
839-
840832
#: The :class:`Router` responsible for routing the message. This is
841833
#: :data:`None` for locally originated messages.
842834
router = None
@@ -928,21 +920,23 @@ def encoded(cls, obj, enc, **kwargs):
928920
raise ValueError('Invalid explicit enc: %r' % (enc,))
929921

930922
@classmethod
931-
def pickled(cls, obj, **kwargs):
923+
def pickled(cls, *args, **kwargs):
932924
"""
933925
Construct a pickled message, setting :attr:`data` to the serialization
934-
of `obj`, and setting remaining fields using `kwargs`.
926+
of each object in `args`, and setting remaining fields using `kwargs`.
935927
936928
:returns:
937929
The new message.
938930
"""
939-
self = cls(enc=cls.ENC_PKL, **kwargs)
940-
try:
941-
self.data = pickle__dumps(obj, protocol=2)
942-
except pickle.PicklingError:
943-
e = sys.exc_info()[1]
944-
self.data = pickle__dumps(CallError(e), protocol=2)
945-
return self
931+
f = BytesIO()
932+
p = Pickler(f, protocol=2)
933+
for obj in args:
934+
try:
935+
p.dump(obj)
936+
except PicklingError:
937+
exc = sys.exc_info()[1]
938+
p.dump(CallError(exc))
939+
return cls(enc=cls.ENC_PKL, data=f.getvalue(), **kwargs)
946940

947941
def reply(self, msg, router=None, **kwargs):
948942
"""
@@ -968,11 +962,6 @@ def reply(self, msg, router=None, **kwargs):
968962
LOG.debug('dropping reply to message with no return address: %r',
969963
msg)
970964

971-
if sys.version_info >= (3, 0):
972-
UNPICKLER_KWARGS = {'encoding': 'bytes'}
973-
else:
974-
UNPICKLER_KWARGS = {}
975-
976965
def _throw_dead(self):
977966
if len(self.data):
978967
raise ChannelError(self.data.decode('utf-8', 'replace'))
@@ -986,49 +975,62 @@ def decode(self, throw=True, throw_dead=True):
986975
if self.enc == self.ENC_BIN: return self.data
987976
raise ValueError('Invalid explicit enc: %r' % (self.enc,))
988977

989-
def unpickle(self, throw=True, throw_dead=True):
978+
def unpickle(self, throw=True, throw_dead=True, find_class=None):
979+
"""
980+
Return the first unpickled stream in :attr:`data`, optionally raise
981+
:exc:`CallError` if the unpickled object is such.
982+
983+
`throw` and `throw_dead` behave the same as with :meth:`unpickle_iter`.
984+
985+
:param find_class:
986+
Callable that takes ``(module, func)`` and returns a constructor.
987+
Defaults to :meth:`_find_global`.
990988
"""
991-
Unpickle :attr:`data`, optionally raising any exceptions present.
989+
if find_class is None: find_class = self._find_global
990+
return next(self.unpickle_iter(throw, throw_dead, find_class))
991+
992+
def unpickle_iter(self, throw=True, throw_dead=True, find_class=find_deny):
993+
"""
994+
Return an iterator of objects unpickled from :attr:`data`, optionally
995+
raising any :exc:`CallError` exceptions present.
992996
993997
:param bool throw_dead:
994998
If :data:`True`, raise exceptions, otherwise it is the caller's
995999
responsibility.
1000+
:param find_class:
1001+
Callable that takes ``(module, func)`` and returns a constructor.
1002+
Default: :func:`find_deny`.
9961003
9971004
:raises CallError:
9981005
The serialized data contained CallError exception.
9991006
:raises ChannelError:
10001007
The `is_dead` field was set.
10011008
"""
1002-
_vv and IOLOG.debug('%r.unpickle()', self)
10031009
if self.enc not in (self.ENC_MGC, self.ENC_PKL):
10041010
raise ValueError(
10051011
'Message %r is not pickled, invalid enc=%r', self, self.enc,
10061012
)
10071013
if throw_dead and self.is_dead:
10081014
self._throw_dead()
10091015

1010-
obj = self._unpickled
1011-
if obj is Message._unpickled:
1012-
fp = BytesIO(self.data)
1013-
unpickler = _Unpickler(fp, **self.UNPICKLER_KWARGS)
1014-
unpickler.find_global = self._find_global
1016+
file = BytesIO(self.data)
1017+
unpickler = Unpickler(file, find_class)
1018+
while file.tell() < len(self.data):
10151019
try:
10161020
# Must occur off the broker thread.
10171021
try:
10181022
obj = unpickler.load()
10191023
except:
10201024
LOG.error('raw pickle was: %r', self.data)
10211025
raise
1022-
self._unpickled = obj
10231026
except (TypeError, ValueError):
10241027
e = sys.exc_info()[1]
10251028
raise StreamError('invalid message: %s', e)
10261029

1027-
if throw:
1028-
if isinstance(obj, CallError):
1030+
if throw and isinstance(obj, CallError):
10291031
raise obj
10301032

1031-
return obj
1033+
yield obj
10321034

10331035
def __repr__(self):
10341036
if len(self.data) > 60:
@@ -1809,8 +1811,7 @@ def _request_resource(self, fullname, resource, callback):
18091811
def _on_load_resource(self, msg):
18101812
if msg.is_dead:
18111813
return
1812-
tup = msg.unpickle()
1813-
fullname, resource, content = tup
1814+
(fullname, resource), content = msg.unpickle_iter()
18141815

18151816
self._lock.acquire()
18161817
try:

mitogen/master.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,26 +1302,17 @@ def _on_get_resource(self, msg):
13021302
content = importlib.resources.read_binary(fullname, resource)
13031303
except (FileNotFoundError, IsADirectoryError):
13041304
content = None
1305-
if content is not None:
1306-
self._send_resource(stream, fullname, resource, content)
1307-
else:
1308-
self._send_not_found(stream, fullname, resource)
13091305

1310-
def _send_resource(self, stream, fullname, resource, content):
13111306
msg = mitogen.core.Message.pickled(
1312-
(fullname, resource, content),
1307+
(fullname, resource), content,
13131308
dst_id=stream.protocol.remote_id,
13141309
handle=mitogen.core.LOAD_RESOURCE,
13151310
)
1316-
self._router._async_route(msg)
13171311

1318-
def _send_not_found(self, stream, fullname, resource):
1319-
msg = mitogen.core.Message.pickled(
1320-
(fullname, resource, None),
1321-
dst_id=stream.protocol.remote_id,
1322-
handle=mitogen.core.LOAD_RESOURCE,
1323-
)
1324-
stream.protocol.send(msg)
1312+
if content is not None:
1313+
self._router._async_route(msg)
1314+
else:
1315+
stream.protocol.send(msg)
13251316

13261317

13271318
class Broker(mitogen.core.Broker):

mitogen/parent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2838,7 +2838,7 @@ def _send_resource(self, stream, fullname, resource):
28382838
content = self.requester._cache[(fullname, resource)]
28392839

28402840
msg = mitogen.core.Message.pickled(
2841-
(fullname, resource, content),
2841+
(fullname, resource), content,
28422842
dst_id=stream.protocol.remote_id,
28432843
handle=mitogen.core.LOAD_RESOURCE,
28442844
)

tests/ansible/hosts/group_vars/all.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pkg_mgr_python_interpreter: python
3838

3939
virtualenv_create_argv:
4040
- virtualenv
41+
- --no-download
4142
- -p
4243
- "{{ virtualenv_python }}"
4344
- "{{ virtualenv_path }}"

tests/message_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import testlib
1313

1414
from mitogen.core import b
15+
from mitogen.core import next
1516

1617

1718
class ConstructorTest(testlib.TestCase):
@@ -317,6 +318,30 @@ def test_custom_object_deserialization_fails(self):
317318
)
318319

319320

321+
class UnpickleIterTest(testlib.TestCase):
322+
def roundtrip(self, *args, **kwargs):
323+
msg1 = mitogen.core.Message.pickled(*args, **kwargs)
324+
return msg1.unpickle_iter()
325+
326+
def test_ints(self):
327+
self.assertEqual(list(self.roundtrip(1, 2, 3)), [1, 2, 3])
328+
329+
def test_mixed(self):
330+
msg = mitogen.core.Message.pickled((u'foo.bar', u'baz.txt'), b('abc'))
331+
self.assertFalse(b('_codecs') in msg.data)
332+
self.assertFalse(b('encode') in msg.data)
333+
self.assertFalse(b('latin1') in msg.data)
334+
335+
parts = msg.unpickle_iter()
336+
self.assertEqual(next(parts), (u'foo.bar', u'baz.txt'))
337+
self.assertEqual(next(parts), b('abc'))
338+
self.assertRaises(StopIteration, next, parts)
339+
340+
def test_default_find_class_denies(self):
341+
msg = mitogen.core.Message.pickled(1j)
342+
self.assertRaises(mitogen.core.UnpicklingError, next, msg.unpickle_iter())
343+
344+
320345
class ReplyTest(testlib.TestCase):
321346
# getting_started.html#rpc-serialization-rules
322347
klass = mitogen.core.Message

0 commit comments

Comments
 (0)