Skip to content

Commit 1b17e85

Browse files
committed
Add bytes_strictness to allow configuring behavior on bytes/text mismatch
Fixes: python-ldap#166
1 parent e148184 commit 1b17e85

File tree

4 files changed

+152
-57
lines changed

4 files changed

+152
-57
lines changed

Doc/bytes_mode.rst

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,37 +43,47 @@ Encoding/decoding to other formats – text, images, etc. – is left to the cal
4343
The bytes mode
4444
--------------
4545

46-
The behavior of python-ldap 3.0 in Python 2 is influenced by a ``bytes_mode``
47-
argument to :func:`ldap.initialize`.
48-
The argument can take these values:
46+
In Python 3, text values are represented as ``str``, the Unicode text type.
4947

50-
``bytes_mode=True``: backwards-compatible
48+
In Python 2, the behavior of python-ldap 3.0 is influenced by a ``bytes_mode``
49+
argument to :func:`ldap.initialize`:
5150

52-
Text values returned from python-ldap are always bytes (``str``).
53-
Text values supplied to python-ldap may be either bytes or Unicode.
54-
The encoding for bytes is always assumed to be UTF-8.
51+
``bytes_mode=True`` (backwards compatible):
52+
Text values are represented as bytes (``str``) encoded using UTF-8.
5553

56-
Not available in Python 3.
54+
``bytes_mode=False`` (future compatible):
55+
Text values are represented as ``unicode``.
5756

58-
``bytes_mode=False``: strictly future-compatible
57+
If not given explicitly, python-ldap will default to ``bytes_mode=True``,
58+
but if an ``unicode`` value supplied to it, if will warn and use that value.
5959

60-
Text values must be represented as ``unicode``.
61-
An error is raised if python-ldap receives a text value as bytes (``str``).
60+
Backwards-compatible behavior is not scheduled for removal until Python 2
61+
itself reaches end of life.
6262

63-
Unspecified: relaxed mode with warnings
6463

65-
Causes a warning on Python 2.
64+
Errors, warnings, and automatic encoding
65+
----------------------------------------
6666

67-
Text values returned from python-ldap are always ``unicode``.
68-
Text values supplied to python-ldap should be ``unicode``;
69-
warnings are emitted when they are not.
67+
While the type of values *returned* from python-ldap is always given by
68+
``bytes_mode``, the behavior for “wrong-type” values *passed in* can be
69+
controlled by the ``bytes_strictness`` argument to :func:`ldap.initialize`:
7070

71-
The warnings are of type :class:`~ldap.LDAPBytesWarning`, which
72-
is a subclass of :class:`BytesWarning` designed to be easily
73-
:ref:`filtered out <filter-bytes-warning>` if needed.
71+
``bytes_strictness='error'`` (default if ``bytes_mode`` is specified):
72+
A ``TypeError`` is raised.
7473

75-
Backwards-compatible behavior is not scheduled for removal until Python 2
76-
itself reaches end of life.
74+
``bytes_strictness='warn'`` (default when ``bytes_mode`` is not given explicitly):
75+
A warning is raised, and the value is encoded/decoded
76+
using the UTF-8 encoding.
77+
78+
The warnings are of type :class:`~ldap.LDAPBytesWarning`, which
79+
is a subclass of :class:`BytesWarning` designed to be easily
80+
:ref:`filtered out <filter-bytes-warning>` if needed.
81+
82+
``bytes_strictness='silent'``:
83+
The value is automatically encoded/decoded using the UTF-8 encoding.
84+
85+
When setting ``bytes_strictness``, an explicit value for ``bytes_mode`` needs
86+
to be given as well.
7787

7888

7989
Porting recommendations

Doc/reference/ldap.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Functions
2929

3030
This module defines the following functions:
3131

32-
.. py:function:: initialize(uri [, trace_level=0 [, trace_file=sys.stdout [, trace_stack_limit=None, [bytes_mode=None]]]]) -> LDAPObject object
32+
.. py:function:: initialize(uri [, trace_level=0 [, trace_file=sys.stdout [, trace_stack_limit=None, [bytes_mode=None, [bytes_strictness=None]]]]]) -> LDAPObject object
3333
3434
Initializes a new connection object for accessing the given LDAP server,
3535
and return an LDAP object (see :ref:`ldap-objects`) used to perform operations
@@ -53,7 +53,8 @@ This module defines the following functions:
5353
*trace_file* specifies a file-like object as target of the debug log and
5454
*trace_stack_limit* specifies the stack limit of tracebacks in debug log.
5555

56-
The *bytes_mode* argument specifies text/bytes behavior under Python 2.
56+
The *bytes_mode* and *bytes_strictness* arguments specify text/bytes
57+
behavior under Python 2.
5758
See :ref:`text-bytes` for a complete documentation.
5859

5960
Possible values for *trace_level* are

Lib/ldap/ldapobject.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ class SimpleLDAPObject:
9393

9494
def __init__(
9595
self,uri,
96-
trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None
96+
trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None,
97+
bytes_strictness=None,
9798
):
9899
self._trace_level = trace_level
99100
self._trace_file = trace_file or sys.stdout
@@ -107,20 +108,26 @@ def __init__(
107108
# Bytes mode
108109
# ----------
109110

110-
# By default, raise a TypeError when receiving invalid args
111-
self.bytes_mode_hardfail = True
112-
if bytes_mode is None and PY2:
113-
_raise_byteswarning(
114-
"Under Python 2, python-ldap uses bytes by default. "
115-
"This will be removed in Python 3 (no bytes for DN/RDN/field names). "
116-
"Please call initialize(..., bytes_mode=False) explicitly.")
117-
bytes_mode = True
118-
# Disable hard failure when running in backwards compatibility mode.
119-
self.bytes_mode_hardfail = False
120-
elif bytes_mode and not PY2:
121-
raise ValueError("bytes_mode is *not* supported under Python 3.")
122-
# On by default on Py2, off on Py3.
111+
if PY2:
112+
if bytes_mode is None:
113+
bytes_mode = True
114+
if bytes_strictness is None:
115+
_raise_byteswarning(
116+
"Under Python 2, python-ldap uses bytes by default. "
117+
"This will be removed in Python 3 (no bytes for "
118+
"DN/RDN/field names). "
119+
"Please call initialize(..., bytes_mode=False) explicitly.")
120+
bytes_strictness = 'warn'
121+
else:
122+
if bytes_strictness is None:
123+
bytes_strictness = 'error'
124+
else:
125+
if bytes_mode:
126+
raise ValueError("bytes_mode is *not* supported under Python 3.")
127+
bytes_mode = False
128+
bytes_strictness = 'error'
123129
self.bytes_mode = bytes_mode
130+
self.bytes_strictness = bytes_strictness
124131

125132
def _bytesify_input(self, arg_name, value):
126133
"""Adapt a value following bytes_mode in Python 2.
@@ -130,38 +137,46 @@ def _bytesify_input(self, arg_name, value):
130137
With bytes_mode ON, takes bytes or None and returns bytes or None.
131138
With bytes_mode OFF, takes unicode or None and returns bytes or None.
132139
133-
This function should be applied on all text inputs (distinguished names
134-
and attribute names in modlists) to convert them to the bytes expected
135-
by the C bindings.
140+
For the wrong argument type (unicode or bytes, respectively),
141+
behavior depends on the bytes_strictness setting.
142+
In all cases, bytes or None are returned (or an exception is raised).
136143
"""
137144
if not PY2:
138145
return value
139-
140146
if value is None:
141147
return value
148+
142149
elif self.bytes_mode:
143150
if isinstance(value, bytes):
144151
return value
152+
elif self.bytes_strictness == 'silent':
153+
pass
154+
elif self.bytes_strictness == 'warn':
155+
_raise_byteswarning(
156+
"Received non-bytes value for '{}' in bytes mode; "
157+
"please choose an explicit "
158+
"option for bytes_mode on your LDAP connection".format(arg_name))
145159
else:
146-
if self.bytes_mode_hardfail:
147160
raise TypeError(
148161
"All provided fields *must* be bytes when bytes mode is on; "
149162
"got type '{}' for '{}'.".format(type(value).__name__, arg_name)
150163
)
151-
else:
152-
_raise_byteswarning(
153-
"Received non-bytes value for '{}' with default (disabled) bytes mode; "
154-
"please choose an explicit "
155-
"option for bytes_mode on your LDAP connection".format(arg_name))
156-
return value.encode('utf-8')
164+
return value.encode('utf-8')
157165
else:
158-
if not isinstance(value, text_type):
166+
if isinstance(value, unicode):
167+
return value.encode('utf-8')
168+
elif self.bytes_strictness == 'silent':
169+
pass
170+
elif self.bytes_strictness == 'warn':
171+
_raise_byteswarning(
172+
"Received non-text value for '{}' with bytes_mode off and "
173+
"bytes_strictness='warn'".format(arg_name))
174+
else:
159175
raise TypeError(
160176
"All provided fields *must* be text when bytes mode is off; "
161177
"got type '{}' for '{}'.".format(type(value).__name__, arg_name)
162178
)
163-
assert not isinstance(value, bytes)
164-
return value.encode('utf-8')
179+
return value
165180

166181
def _bytesify_modlist(self, arg_name, modlist, with_opcode):
167182
"""Adapt a modlist according to bytes_mode.
@@ -1064,7 +1079,7 @@ class ReconnectLDAPObject(SimpleLDAPObject):
10641079
def __init__(
10651080
self,uri,
10661081
trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None,
1067-
retry_max=1,retry_delay=60.0
1082+
bytes_strictness=None, retry_max=1, retry_delay=60.0
10681083
):
10691084
"""
10701085
Parameters like SimpleLDAPObject.__init__() with these
@@ -1078,7 +1093,9 @@ def __init__(
10781093
self._uri = uri
10791094
self._options = []
10801095
self._last_bind = None
1081-
SimpleLDAPObject.__init__(self,uri,trace_level,trace_file,trace_stack_limit,bytes_mode)
1096+
SimpleLDAPObject.__init__(self, uri, trace_level, trace_file,
1097+
trace_stack_limit, bytes_mode,
1098+
bytes_strictness=bytes_strictness)
10821099
self._reconnect_lock = ldap.LDAPLock(desc='reconnect lock within %s' % (repr(self)))
10831100
self._retry_max = retry_max
10841101
self._retry_delay = retry_delay
@@ -1097,6 +1114,11 @@ def __getstate__(self):
10971114

10981115
def __setstate__(self,d):
10991116
"""set up the object from pickled data"""
1117+
hardfail = d.get('bytes_mode_hardfail')
1118+
if hardfail:
1119+
d.setdefault('bytes_strictness', 'error')
1120+
else:
1121+
d.setdefault('bytes_strictness', 'warn')
11001122
self.__dict__.update(d)
11011123
self._last_bind = getattr(SimpleLDAPObject, self._last_bind[0]), self._last_bind[1], self._last_bind[2]
11021124
self._ldap_object_lock = self._ldap_lock()

Tests/t_ldapobject.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ def test_search_keys_are_text(self):
162162
for value in values:
163163
self.assertEqual(type(value), bytes)
164164

165-
def _get_bytes_ldapobject(self, explicit=True):
165+
def _get_bytes_ldapobject(self, explicit=True, **kwargs):
166166
if explicit:
167-
kwargs = {'bytes_mode': True}
167+
kwargs.setdefault('bytes_mode', True)
168168
else:
169169
kwargs = {}
170170
return self._open_ldap_conn(
@@ -231,6 +231,68 @@ def test_unset_bytesmode_search_warns_bytes(self):
231231
l.search_s(base.encode('utf-8'), ldap.SCOPE_SUBTREE, b'(cn=Foo*)', ['*'])
232232
l.search_s(base, ldap.SCOPE_SUBTREE, b'(cn=Foo*)', [b'*'])
233233

234+
def _search_wrong_type(self, bytes_mode, strictness):
235+
if bytes_mode:
236+
l = self._get_bytes_ldapobject(bytes_strictness=strictness)
237+
else:
238+
l = self._open_ldap_conn(bytes_mode=False,
239+
bytes_strictness=strictness)
240+
base = 'cn=Foo1,' + self.server.suffix
241+
if not bytes_mode:
242+
base = base.encode('utf-8')
243+
result = l.search_s(base, scope=ldap.SCOPE_SUBTREE)
244+
return result[0][-1]['cn']
245+
246+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
247+
def test_bytesmode_silent(self):
248+
with warnings.catch_warnings(record=True) as w:
249+
warnings.resetwarnings()
250+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
251+
self._search_wrong_type(bytes_mode=True, strictness='silent')
252+
self.assertEqual(w, [])
253+
254+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
255+
def test_bytesmode_warn(self):
256+
with warnings.catch_warnings(record=True) as w:
257+
warnings.resetwarnings()
258+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
259+
self._search_wrong_type(bytes_mode=True, strictness='warn')
260+
self.assertEqual(len(w), 1)
261+
262+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
263+
def test_bytesmode_error(self):
264+
with warnings.catch_warnings(record=True) as w:
265+
warnings.resetwarnings()
266+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
267+
with self.assertRaises(TypeError):
268+
self._search_wrong_type(bytes_mode=True, strictness='error')
269+
self.assertEqual(w, [])
270+
271+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
272+
def test_textmode_silent(self):
273+
with warnings.catch_warnings(record=True) as w:
274+
warnings.resetwarnings()
275+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
276+
self._search_wrong_type(bytes_mode=True, strictness='silent')
277+
self.assertEqual(w, [])
278+
279+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
280+
def test_textmode_warn(self):
281+
with warnings.catch_warnings(record=True) as w:
282+
warnings.resetwarnings()
283+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
284+
self._search_wrong_type(bytes_mode=True, strictness='warn')
285+
self.assertEqual(len(w), 1)
286+
287+
@unittest.skipUnless(PY2, "no bytes_mode under Py3")
288+
def test_textmode_error(self):
289+
with warnings.catch_warnings(record=True) as w:
290+
warnings.resetwarnings()
291+
warnings.simplefilter('always', ldap.LDAPBytesWarning)
292+
with self.assertRaises(TypeError):
293+
self._search_wrong_type(bytes_mode=True, strictness='error')
294+
self.assertEqual(w, [])
295+
234296
def test_search_accepts_unicode_dn(self):
235297
base = self.server.suffix
236298
l = self._ldap_conn
@@ -470,7 +532,7 @@ def test_ldapbyteswarning(self):
470532
self.assertIs(msg.category, ldap.LDAPBytesWarning)
471533
self.assertEqual(
472534
text_type(msg.message),
473-
"Received non-bytes value for 'base' with default (disabled) bytes "
535+
"Received non-bytes value for 'base' in bytes "
474536
"mode; please choose an explicit option for bytes_mode on your "
475537
"LDAP connection"
476538
)
@@ -632,7 +694,7 @@ def test103_reconnect_get_state(self):
632694
str('_trace_stack_limit'): 5,
633695
str('_uri'): self.server.ldap_uri,
634696
str('bytes_mode'): l1.bytes_mode,
635-
str('bytes_mode_hardfail'): l1.bytes_mode_hardfail,
697+
str('bytes_strictness'): l1.bytes_strictness,
636698
str('timeout'): -1,
637699
},
638700
)

0 commit comments

Comments
 (0)