diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 738d86fa..00000000
--- a/.coveragerc
+++ /dev/null
@@ -1,27 +0,0 @@
-[run]
-branch = True
-source =
- ldap
- ldif
- ldapurl
- slapdtest
-
-[paths]
-source =
- Lib/
- .tox/*/lib/python*/site-packages/
-
-[report]
-ignore_errors = False
-precision = 1
-exclude_lines =
- pragma: no cover
- raise NotImplementedError
- if 0:
- if __name__ == .__main__.:
- if PY2
- if not PY2
-
-[html]
-directory = build/htmlcov
-title = python-ldap coverage report
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f835d76..36fde319 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,7 +1,7 @@
---
name: CI
-on:
+on:
push:
pull_request:
schedule:
@@ -20,8 +20,6 @@ jobs:
fail-fast: false
matrix:
python-version:
- - "3.7"
- - "3.8"
- "3.9"
- "3.10"
- "3.11"
@@ -29,14 +27,11 @@ jobs:
- "3.13"
- "pypy3.9"
- "pypy3.10"
- image:
+ image:
- "ubuntu-22.04"
- include:
- - python-version: "3.6"
- image: "ubuntu-20.04"
steps:
- name: Checkout
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v6"
- name: Install apt dependencies
run: |
set -ex
@@ -45,7 +40,7 @@ jobs:
- name: Disable AppArmor
run: sudo aa-disable /usr/sbin/slapd
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
diff --git a/.github/workflows/tox-fedora.yml b/.github/workflows/tox-fedora.yml
index 4c4c18f0..cef8df1a 100644
--- a/.github/workflows/tox-fedora.yml
+++ b/.github/workflows/tox-fedora.yml
@@ -9,7 +9,7 @@ jobs:
tox_test:
name: Tox env "${{matrix.tox_env}}" on Fedora
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Run Tox tests
uses: fedora-python/tox-github-action@main
with:
@@ -21,20 +21,15 @@ jobs:
strategy:
matrix:
tox_env:
- - py36
- - py37
- - py38
- py39
- py310
- py311
- py312
- py313
- - c90-py36
- - c90-py37
- py3-nosasltls
- py3-trace
- pypy3
- doc
# Use GitHub's Linux Docker host
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
diff --git a/.gitignore b/.gitignore
index bab21878..75a13538 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,6 @@
*.pyc
__pycache__/
.tox
-.coverage*
-!.coveragerc
/.cache
/.pytest_cache
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..91fb6028
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,22 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the version of Python and other tools you might need
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.11"
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+ configuration: Doc/conf.py
+
+# We recommend specifying your dependencies to enable reproducible builds:
+# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+ install:
+ - requirements: Doc/requirements.txt
diff --git a/CHANGES b/CHANGES
index 0491b6ef..05fcf4b6 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,32 @@
+Released 3.4.5 2025-10-10
+
+Security fixes:
+* CVE-2025-61911 (GHSA-r7r6-cc7p-4v5m): Enforce ``str`` input in
+ ``ldap.filter.escape_filter_chars`` with ``escape_mode=1``; ensure proper
+ escaping. (thanks to lukas-eu)
+* CVE-2025-61912 (GHSA-p34h-wq7j-h5v6): Correct NUL escaping in
+ ``ldap.dn.escape_dn_chars`` to ``\00`` per RFC 4514. (thanks to aradona91)
+
+Fixes:
+* ReconnectLDAPObject now properly reconnects on UNAVAILABLE, CONNECT_ERROR
+ and TIMEOUT exceptions (previously only SERVER_DOWN), fixing reconnection
+ issues especially during server restarts
+* Fixed syncrepl.py to use named constants instead of raw decimal values
+ for result types
+* Fixed error handling in SearchNoOpMixIn to prevent a undefined variable error
+
+Tests:
+* Added comprehensive reconnection test cases including concurrent operation
+ handling and server restart scenarios
+
+Doc/
+* Updated installation docs and fixed various documentation typos
+* Added ReadTheDocs configuration file
+
+Infrastructure:
+* Add testing and document support for Python 3.13
+
+----------------------------------------------------------------
Released 3.4.4 2022-11-17
Fixes:
diff --git a/Demo/pyasn1/syncrepl.py b/Demo/pyasn1/syncrepl.py
index f1f24e19..754b237a 100644
--- a/Demo/pyasn1/syncrepl.py
+++ b/Demo/pyasn1/syncrepl.py
@@ -76,7 +76,7 @@ def syncrepl_entry(self, dn, attributes, uuid):
logger.debug('Detected %s of entry %r', change_type, dn)
# If we have a cookie then this is not our first time being run,
# so it must be a change
- if 'ldap_cookie' in self.__data:
+ if 'cookie' in self.__data:
self.perform_application_sync(dn, attributes, previous_attributes)
def syncrepl_delete(self,uuids):
@@ -98,7 +98,7 @@ def syncrepl_present(self,uuids,refreshDeletes=False):
deletedEntries = [
uuid
for uuid in self.__data.keys()
- if uuid not in self.__presentUUIDs and uuid != 'ldap_cookie'
+ if uuid not in self.__presentUUIDs and uuid != 'cookie'
]
self.syncrepl_delete( deletedEntries )
# Phase is now completed, reset the list
diff --git a/Doc/installing.rst b/Doc/installing.rst
index 03e7a295..1c7ec8c3 100644
--- a/Doc/installing.rst
+++ b/Doc/installing.rst
@@ -76,12 +76,23 @@ The CVS repository of FreeBSD contains the package
macOS
-----
-You can install directly with pip::
+You can install directly with pip. First install Xcode command line tools::
$ xcode-select --install
- $ pip install python-ldap \
- --global-option=build_ext \
- --global-option="-I$(xcrun --show-sdk-path)/usr/include/sasl"
+
+Then install python-ldap::
+
+ $ pip install python-ldap
+
+For custom installations, you may need to set environment variables::
+
+ $ export CPPFLAGS="-I$(xcrun --show-sdk-path)/usr/include/sasl"
+ $ pip install python-ldap
+
+If using Homebrew::
+
+ $ brew install openldap
+ $ pip install python-ldap
.. _install-source:
@@ -90,11 +101,14 @@ Installing from Source
======================
-python-ldap is built and installed using the Python setuptools.
-From a source repository::
+python-ldap is built and installed using modern Python packaging standards
+with pyproject.toml configuration. From a source repository::
+
+ $ pip install .
+
+For development installation with editable mode::
- $ python -m pip install setuptools
- $ python setup.py install
+ $ pip install -e .
If you have more than one Python interpreter installed locally, you should
use the same one you plan to use python-ldap with.
diff --git a/Doc/reference/ldap-syncrepl.rst b/Doc/reference/ldap-syncrepl.rst
index b3b2cf9a..046b15a9 100644
--- a/Doc/reference/ldap-syncrepl.rst
+++ b/Doc/reference/ldap-syncrepl.rst
@@ -20,3 +20,6 @@ This module defines the following classes:
.. autoclass:: ldap.syncrepl.SyncreplConsumer
:members:
+
+.. autoclass:: ldap.syncrepl.OpenLDAPSyncreplCookie
+ :members:
diff --git a/Doc/reference/ldap.rst b/Doc/reference/ldap.rst
index d059dfa4..397b7663 100644
--- a/Doc/reference/ldap.rst
+++ b/Doc/reference/ldap.rst
@@ -38,7 +38,8 @@ This module defines the following functions:
The *uri* parameter may be a comma- or whitespace-separated list of URIs
containing only the schema, the host, and the port fields. Note that
when using multiple URIs you cannot determine to which URI your client
- gets connected.
+ gets connected. If *uri* is :py:const:`None`, the default URIs from
+ ``ldap.conf`` or :py:const:`OPT_URI` global option will be used.
If *fileno* parameter is given then the file descriptor will be used to
connect to an LDAP server. The *fileno* must either be a socket file
@@ -604,13 +605,13 @@ The module defines the following exceptions:
.. py:exception:: COMPARE_FALSE
A compare operation returned false.
- (This exception should only be seen asynchronous operations, because
+ (This exception should only be seen in asynchronous operations, because
:py:meth:`~LDAPObject.compare_s()` returns a boolean result.)
.. py:exception:: COMPARE_TRUE
A compare operation returned true.
- (This exception should only be seen asynchronous operations, because
+ (This exception should only be seen in asynchronous operations, because
:py:meth:`~LDAPObject.compare_s()` returns a boolean result.)
.. py:exception:: CONFIDENTIALITY_REQUIRED
@@ -972,6 +973,15 @@ and wait for and return with the server's result, or with
The *dn* and *attr* arguments are text strings; see :ref:`bytes_mode`.
+.. py:method:: LDAPObject.connect() -> None
+
+ Opens a connection to the server if one is not established already. If that
+ fails, an instance of :py:exc:`ldap.LDAPError` is raised.
+
+ Requires libldap 2.5+ and will fail with :py:exc:`NotImplementedError`
+ if that is not met.
+
+
.. py:method:: LDAPObject.delete(dn) -> int
.. py:method:: LDAPObject.delete_s(dn) -> None
@@ -1364,7 +1374,7 @@ and wait for and return with the server's result, or with
This synchronous method implements the LDAP "Who Am I?"
extended operation.
- It is useful for finding out to find out which identity
+ It is useful for finding out which identity
is assumed by the LDAP server after a SASL bind.
.. seealso::
diff --git a/Doc/spelling_wordlist.txt b/Doc/spelling_wordlist.txt
index e2150d9a..4381ebee 100644
--- a/Doc/spelling_wordlist.txt
+++ b/Doc/spelling_wordlist.txt
@@ -60,6 +60,7 @@ func
Gohlke
GPG
Heimdal
+Homebrew
hostport
hrefTarget
hrefText
@@ -106,6 +107,7 @@ previousDN
processResultsCount
Proxied
py
+pyproject
pytest
rdn
readthedocs
@@ -146,6 +148,7 @@ syncrepl
syntaxes
timelimit
TLS
+toml
tracebacks
tuple
tuples
@@ -162,5 +165,6 @@ userPassword
usr
uuids
Valgrind
+Xcode
whitespace
workflow
diff --git a/INSTALL b/INSTALL
index b9b13d2d..224df4a4 100644
--- a/INSTALL
+++ b/INSTALL
@@ -1,8 +1,7 @@
Quick build instructions:
edit setup.cfg (see Build/ for platform-specific examples)
- python setup.py build
- python setup.py install
+ pip install .
Detailed instructions are in Doc/installing.rst, or online at:
diff --git a/Lib/ldap/cidict.py b/Lib/ldap/cidict.py
index f846fd29..65041e0a 100644
--- a/Lib/ldap/cidict.py
+++ b/Lib/ldap/cidict.py
@@ -85,7 +85,7 @@ def strlist_minus(a,b):
a,b are supposed to be lists of case-insensitive strings.
"""
warnings.warn(
- "strlist functions are deprecated and will be removed in 3.5",
+ "strlist functions are deprecated and will be removed in 4.0",
category=DeprecationWarning,
stacklevel=2,
)
@@ -105,7 +105,7 @@ def strlist_intersection(a,b):
Return intersection of two lists of case-insensitive strings a,b.
"""
warnings.warn(
- "strlist functions are deprecated and will be removed in 3.5",
+ "strlist functions are deprecated and will be removed in 4.0",
category=DeprecationWarning,
stacklevel=2,
)
@@ -125,7 +125,7 @@ def strlist_union(a,b):
Return union of two lists of case-insensitive strings a,b.
"""
warnings.warn(
- "strlist functions are deprecated and will be removed in 3.5",
+ "strlist functions are deprecated and will be removed in 4.0",
category=DeprecationWarning,
stacklevel=2,
)
diff --git a/Lib/ldap/controls/openldap.py b/Lib/ldap/controls/openldap.py
index 24040ed7..26c76868 100644
--- a/Lib/ldap/controls/openldap.py
+++ b/Lib/ldap/controls/openldap.py
@@ -51,6 +51,7 @@ class SearchNoOpMixIn:
"""
def noop_search_st(self,base,scope=ldap.SCOPE_SUBTREE,filterstr='(objectClass=*)',timeout=-1):
+ msg_id = None
try:
msg_id = self.search_ext(
base,
@@ -66,9 +67,10 @@ def noop_search_st(self,base,scope=ldap.SCOPE_SUBTREE,filterstr='(objectClass=*)
ldap.TIMELIMIT_EXCEEDED,
ldap.SIZELIMIT_EXCEEDED,
ldap.ADMINLIMIT_EXCEEDED
- ) as e:
- self.abandon(msg_id)
- raise e
+ ):
+ if msg_id is not None:
+ self.abandon(msg_id)
+ raise
else:
noop_srch_ctrl = [
c
diff --git a/Lib/ldap/dn.py b/Lib/ldap/dn.py
index a9d96846..64d7d0e9 100644
--- a/Lib/ldap/dn.py
+++ b/Lib/ldap/dn.py
@@ -26,7 +26,8 @@ def escape_dn_chars(s):
s = s.replace('>' ,'\\>')
s = s.replace(';' ,'\\;')
s = s.replace('=' ,'\\=')
- s = s.replace('\000' ,'\\\000')
+ # RFC 4514 requires NULL (U+0000) to be escaped as hex pair "\00"
+ s = s.replace('\x00' ,'\\00')
if s[-1]==' ':
s = ''.join((s[:-1],'\\ '))
if s[0]=='#' or s[0]==' ':
@@ -48,12 +49,17 @@ def str2dn(dn,flags=0):
return ldap.functions._ldap_function_call(None,_ldap.str2dn,dn,flags)
-def dn2str(dn):
+def dn2str(dn, flags=0):
"""
This function takes a decomposed DN as parameter and returns
- a single string. It's the inverse to str2dn() but will always
- return a DN in LDAPv3 format compliant to RFC 4514.
+ a single string. It's the inverse to str2dn() but will by default always
+ return a DN in LDAPv3 format compliant to RFC 4514 if not otherwise specified
+ via flags.
+
+ See also the OpenLDAP man-page ldap_dn2str(3)
"""
+ if flags:
+ return ldap.functions._ldap_function_call(None, _ldap.dn2str, dn, flags)
return ','.join([
'+'.join([
'='.join((atype,escape_dn_chars(avalue or '')))
@@ -61,6 +67,7 @@ def dn2str(dn):
for rdn in dn
])
+
def explode_dn(dn, notypes=False, flags=0):
"""
explode_dn(dn [, notypes=False [, flags=0]]) -> list
@@ -116,3 +123,8 @@ def is_dn(s,flags=0):
return False
else:
return True
+
+
+def normalize(s, flags=0):
+ """Returns a normalized distinguished name (DN)"""
+ return dn2str(str2dn(s, flags), flags)
diff --git a/Lib/ldap/extop/dds.py b/Lib/ldap/extop/dds.py
index 7fab0813..a970d71d 100644
--- a/Lib/ldap/extop/dds.py
+++ b/Lib/ldap/extop/dds.py
@@ -35,6 +35,7 @@ class RefreshRequestValue(univ.Sequence):
)
def __init__(self,requestName=None,entryName=None,requestTtl=None):
+ super().__init__(requestName or self.requestName, b'')
self.entryName = entryName
self.requestTtl = requestTtl or self.defaultRequestTtl
diff --git a/Lib/ldap/filter.py b/Lib/ldap/filter.py
index 782737aa..5bd41b21 100644
--- a/Lib/ldap/filter.py
+++ b/Lib/ldap/filter.py
@@ -24,6 +24,8 @@ def escape_filter_chars(assertion_value,escape_mode=0):
If 1 all NON-ASCII chars are escaped.
If 2 all chars are escaped.
"""
+ if not isinstance(assertion_value, str):
+ raise TypeError("assertion_value must be of type str.")
if escape_mode:
r = []
if escape_mode==1:
diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py
index 7a9c17f6..057fe71a 100644
--- a/Lib/ldap/ldapobject.py
+++ b/Lib/ldap/ldapobject.py
@@ -67,7 +67,7 @@ class SimpleLDAPObject:
}
def __init__(
- self,uri,
+ self,uri=None,
trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None,
bytes_strictness=None, fileno=None
):
@@ -171,6 +171,13 @@ def fileno(self):
"""
return self.get_option(ldap.OPT_DESC)
+ def connect(self):
+ """
+ connect() -> None
+ Establishes LDAP connection if needed.
+ """
+ return self._ldap_call(self._l.connect)
+
def abandon_ext(self,msgid,serverctrls=None,clientctrls=None):
"""
abandon_ext(msgid[,serverctrls=None[,clientctrls=None]]) -> None
@@ -521,7 +528,7 @@ def result(self,msgid=ldap.RES_ANY,all=1,timeout=None):
The method returns a tuple of the form (result_type,
result_data). The result_type is one of the constants RES_*.
- See search() for a description of the search result's
+ See search_ext() for a description of the search result's
result_data, otherwise the result_data is normally meaningless.
The result() method will block for timeout seconds, or
@@ -588,7 +595,7 @@ def search_ext(self,base,scope,filterstr=None,attrlist=None,attrsonly=0,serverct
values are stored in a list as dictionary value.
The DN in dn is extracted using the underlying ldap_get_dn(),
- which may raise an exception of the DN is malformed.
+ which may raise an exception if the DN is malformed.
If attrsonly is non-zero, the values of attrs will be
meaningless (they are not transmitted in the result).
@@ -820,8 +827,7 @@ def get_naming_contexts(self):
class ReconnectLDAPObject(SimpleLDAPObject):
"""
:py:class:`SimpleLDAPObject` subclass whose synchronous request methods
- automatically reconnect and re-try in case of server failure
- (:exc:`ldap.SERVER_DOWN`).
+ automatically reconnect and re-try in case of server failure.
The first arguments are same as for the :py:func:`~ldap.initialize()`
function.
@@ -833,6 +839,10 @@ class ReconnectLDAPObject(SimpleLDAPObject):
* retry_delay: specifies the time in seconds between reconnect attempts.
This class also implements the pickle protocol.
+
+ .. versionadded:: 3.4.5
+ The exceptions :py:exc:`ldap.SERVER_DOWN`, :py:exc:`ldap.UNAVAILABLE`, :py:exc:`ldap.CONNECT_ERROR` and
+ :py:exc:`ldap.TIMEOUT` (configurable via :py:attr:`_reconnect_exceptions`) now trigger a reconnect.
"""
__transient_attrs__ = {
@@ -842,9 +852,10 @@ class ReconnectLDAPObject(SimpleLDAPObject):
'_reconnect_lock',
'_last_bind',
}
+ _reconnect_exceptions = (ldap.SERVER_DOWN, ldap.UNAVAILABLE, ldap.CONNECT_ERROR, ldap.TIMEOUT)
def __init__(
- self,uri,
+ self,uri=None,
trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None,
bytes_strictness=None, retry_max=1, retry_delay=60.0, fileno=None
):
@@ -877,7 +888,10 @@ def __getstate__(self):
for k,v in self.__dict__.items()
if k not in self.__transient_attrs__
}
- state['_last_bind'] = self._last_bind[0].__name__, self._last_bind[1], self._last_bind[2]
+ if self._last_bind is None:
+ state['_last_bind'] = None
+ else:
+ state['_last_bind'] = self._last_bind[0].__name__, self._last_bind[1], self._last_bind[2]
return state
def __setstate__(self,d):
@@ -888,7 +902,8 @@ def __setstate__(self,d):
else:
d.setdefault('bytes_strictness', 'warn')
self.__dict__.update(d)
- self._last_bind = getattr(SimpleLDAPObject, self._last_bind[0]), self._last_bind[1], self._last_bind[2]
+ if self._last_bind is not None:
+ self._last_bind = getattr(SimpleLDAPObject, self._last_bind[0]), self._last_bind[1], self._last_bind[2]
self._ldap_object_lock = self._ldap_lock()
self._reconnect_lock = ldap.LDAPLock(desc='reconnect lock within %s' % (repr(self)))
# XXX cannot pickle file, use default trace file
@@ -970,7 +985,7 @@ def _apply_method_s(self,func,*args,**kwargs):
self.reconnect(self._uri,retry_max=self._retry_max,retry_delay=self._retry_delay,force=False)
try:
return func(self,*args,**kwargs)
- except ldap.SERVER_DOWN:
+ except self._reconnect_exceptions:
# Try to reconnect
self.reconnect(self._uri,retry_max=self._retry_max,retry_delay=self._retry_delay,force=True)
# Re-try last operation
diff --git a/Lib/ldap/pkginfo.py b/Lib/ldap/pkginfo.py
index 18ead66c..2ac6852d 100644
--- a/Lib/ldap/pkginfo.py
+++ b/Lib/ldap/pkginfo.py
@@ -1,6 +1,6 @@
"""
meta attributes for packaging which does not import any dependencies
"""
-__version__ = '3.4.4'
+__version__ = '3.4.5'
__author__ = 'python-ldap project'
__license__ = 'Python style'
diff --git a/Lib/ldap/schema/subentry.py b/Lib/ldap/schema/subentry.py
index b83d819b..3f73df71 100644
--- a/Lib/ldap/schema/subentry.py
+++ b/Lib/ldap/schema/subentry.py
@@ -476,10 +476,10 @@ def urlfetch(uri,trace_level=0):
l.unbind_s()
del l
else:
- ldif_file = urlopen(uri)
- ldif_parser = ldif.LDIFRecordList(ldif_file,max_entries=1)
- ldif_parser.parse()
- subschemasubentry_dn,s_temp = ldif_parser.all_records[0]
+ with urlopen(uri) as ldif_file:
+ ldif_parser = ldif.LDIFRecordList(ldif_file,max_entries=1)
+ ldif_parser.parse()
+ subschemasubentry_dn,s_temp = ldif_parser.all_records[0]
# Work-around for mixed-cased attribute names
subschemasubentry_entry = ldap.cidict.cidict()
s_temp = s_temp or {}
diff --git a/Lib/ldap/syncrepl.py b/Lib/ldap/syncrepl.py
index fd0c1285..0e1b6a3f 100644
--- a/Lib/ldap/syncrepl.py
+++ b/Lib/ldap/syncrepl.py
@@ -4,6 +4,7 @@
See https://www.python-ldap.org/ for project details.
"""
+from typing import AnyStr, Dict, List, Tuple, Union
from uuid import UUID
# Imports from pyasn1
@@ -15,6 +16,7 @@
from ldap import RES_SEARCH_RESULT, RES_SEARCH_ENTRY, RES_INTERMEDIATE
__all__ = [
+ 'OpenLDAPSyncreplCookie',
'SyncreplConsumer',
]
@@ -535,3 +537,71 @@ def syncrepl_refreshdone(self):
follows.
"""
pass
+
+
+class OpenLDAPSyncreplCookie:
+ """
+ OpenLDAPSyncreplCookie - allows a consumer to track a cookie across a
+ refreshAndPersist syncrepl session against a multi-provider OpenLDAP cluster
+ """
+
+ rid: int = 0
+ sid: int = 0
+ _csnset: Dict[int, str]
+
+ def __init__(self, cookie: AnyStr = "") -> None:
+ self._csnset = {}
+
+ if cookie:
+ self.update(cookie)
+
+ def _parse_csn(self, csn: str) -> Tuple[str, str, str, str]:
+ time, order, sid, other = csn.split('#', 3)
+ return (time, order, sid, other)
+
+ def _parse_cookie(self, cookie: AnyStr) -> Dict[str, Union[str, List[str]]]:
+ if isinstance(cookie, bytes):
+ cookie = cookie.decode()
+
+ result = {}
+ parts = cookie.split(',')
+ for part in parts:
+ if part.startswith('rid='):
+ result['rid'] = part[4:]
+ elif part.startswith('sid='):
+ result['sid'] = part[4:]
+ elif part.startswith('csn='):
+ result['csn'] = part[4:].split(';')
+ elif part.startswith('delcsn='):
+ result['delcsn'] = part[7:]
+ else:
+ # Did not recognize this cookie part
+ pass
+ return result
+
+ def update(self, cookie: AnyStr):
+ """
+ Update the CSN set based on a cookie we just received, use in
+ syncrepl_set_cookie() to track the session state.
+ """
+ components = self._parse_cookie(cookie)
+ for csn in components.get('csn', []):
+ _, _, sid, _ = self._parse_csn(csn)
+ if sid not in self._csnset or self._csnset[sid] < csn:
+ self._csnset[sid] = csn
+
+ return self
+
+ def unparse(self) -> str:
+ """
+ Return the cookie as a string, use in syncrepl_get_cookie() or when
+ storing the state for later use.
+ """
+ cookie = 'rid={:03},sid={:03x}'.format(self.rid or 0, self.sid or 0)
+ if self._csnset:
+ cookie += ',csn='
+ cookie += ';'.join(csn for sid, csn in sorted(self._csnset.items()))
+ return cookie
+
+ def __str__(self):
+ return self.unparse()
diff --git a/Lib/ldapurl.py b/Lib/ldapurl.py
index b4dfd890..57900028 100644
--- a/Lib/ldapurl.py
+++ b/Lib/ldapurl.py
@@ -4,7 +4,7 @@
See https://www.python-ldap.org/ for details.
"""
-__version__ = '3.4.4'
+__version__ = '3.4.5'
__all__ = [
# constants
diff --git a/Lib/ldif.py b/Lib/ldif.py
index fa41321c..356f95ea 100644
--- a/Lib/ldif.py
+++ b/Lib/ldif.py
@@ -3,7 +3,7 @@
See https://www.python-ldap.org/ for details.
"""
-__version__ = '3.4.4'
+__version__ = '3.4.5'
__all__ = [
# constants
@@ -373,7 +373,8 @@ def _next_key_and_value(self):
if self._process_url_schemes:
u = urlparse(url)
if u[0] in self._process_url_schemes:
- attr_value = urlopen(url).read()
+ with urlopen(url) as fd:
+ attr_value = fd.read()
else:
# All values should be valid ascii; we support UTF-8 as a
# non-official, backwards compatibility layer.
diff --git a/Lib/slapdtest/__init__.py b/Lib/slapdtest/__init__.py
index 7c410180..0fabc4c4 100644
--- a/Lib/slapdtest/__init__.py
+++ b/Lib/slapdtest/__init__.py
@@ -4,7 +4,7 @@
See https://www.python-ldap.org/ for details.
"""
-__version__ = '3.4.4'
+__version__ = '3.4.5'
from slapdtest._slapdtest import SlapdObject, SlapdTestCase, SysLogHandler
from slapdtest._slapdtest import requires_ldapi, requires_sasl, requires_tls
diff --git a/Lib/slapdtest/_slapdtest.py b/Lib/slapdtest/_slapdtest.py
index 36841110..b60313b0 100644
--- a/Lib/slapdtest/_slapdtest.py
+++ b/Lib/slapdtest/_slapdtest.py
@@ -41,6 +41,11 @@
cn: module
olcModuleLoad: back_%(database)s
+dn: olcDatabase=config,cn=config
+objectClass: olcDatabaseConfig
+olcDatabase: config
+olcRootDN: %(rootdn)s
+
dn: olcDatabase=%(database)s,cn=config
objectClass: olcDatabaseConfig
objectClass: olcMdbConfig
@@ -259,7 +264,6 @@ def _find_commands(self):
self.PATH_LDAPDELETE = self._find_command('ldapdelete')
self.PATH_LDAPMODIFY = self._find_command('ldapmodify')
self.PATH_LDAPWHOAMI = self._find_command('ldapwhoami')
- self.PATH_SLAPADD = self._find_command('slapadd')
self.PATH_SLAPD = os.environ.get('SLAPD', None)
if not self.PATH_SLAPD:
@@ -276,7 +280,7 @@ def _find_command(self, cmd, in_sbin=False):
if command is None:
raise ValueError(
"Command '{}' not found. Set the {} environment variable to "
- "override slapdtest's search path.".format(cmd, var_name)
+ "override slapdtest's search path: {}.".format(cmd, var_name, path)
)
return command
@@ -347,6 +351,7 @@ def gen_config(self):
'cafile': self.cafile,
'servercert': self.servercert,
'serverkey': self.serverkey,
+ 'slapd_path': self.SBIN_PATH,
}
return self.slapd_conf_template % config_dict
@@ -407,12 +412,17 @@ def _start_slapd(self):
'-F', self._slapd_conf,
'-h', ' '.join(urls),
]
+ stderr = None
if self._log.isEnabledFor(logging.DEBUG):
slapd_args.extend(['-d', '-1'])
+ stderr = os.open(os.path.join(self.testrundir, 'slapd.log'), os.O_WRONLY|os.O_CREAT)
else:
slapd_args.extend(['-d', '0'])
self._log.info('starting slapd: %r', ' '.join(slapd_args))
- self._proc = subprocess.Popen(slapd_args)
+ self._proc = subprocess.Popen(slapd_args, stderr=stderr)
+ if stderr is not None:
+ os.close(stderr)
+ stderr = None
# Waits until the LDAP server socket is open, or slapd crashed
deadline = time.monotonic() + 10
# no cover to avoid spurious coverage changes, see
@@ -452,7 +462,7 @@ def start(self):
self._proc.pid, self.ldap_uri, self.ldapi_uri
)
- def stop(self):
+ def stop(self, cleanup=True):
"""
Stops the slapd server, and waits for it to terminate and cleans up
"""
@@ -460,15 +470,24 @@ def stop(self):
self._log.debug('stopping slapd with pid %d', self._proc.pid)
self._proc.terminate()
self.wait()
- self._cleanup_rundir()
+ if cleanup:
+ self._cleanup_rundir()
atexit.unregister(self.stop)
def restart(self):
"""
Restarts the slapd server with same data
"""
- self._proc.terminate()
+ self.terminate()
self.wait()
+ self.resume()
+
+ def terminate(self):
+ """Terminate slapd server"""
+ self._proc.terminate()
+
+ def resume(self):
+ """Start slapd server"""
self._start_slapd()
def wait(self):
@@ -500,14 +519,17 @@ def _cli_auth_args(self):
# no cover to avoid spurious coverage changes
def _cli_popen(self, ldapcommand, extra_args=None, ldap_uri=None,
- stdin_data=None): # pragma: no cover
+ stdin_data=None, tool=None): # pragma: no cover
if ldap_uri is None:
ldap_uri = self.default_ldap_uri
if ldapcommand.split("/")[-1].startswith("ldap"):
args = [ldapcommand, '-H', ldap_uri] + self._cli_auth_args()
else:
- args = [ldapcommand, '-F', self._slapd_conf]
+ if tool:
+ args = [ldapcommand, '-T', tool, '-F', self._slapd_conf]
+ else:
+ args = [ldapcommand, '-F', self._slapd_conf]
args += (extra_args or [])
@@ -566,9 +588,10 @@ def slapadd(self, ldif, extra_args=None):
Runs slapadd on this slapd instance, passing it the ldif content
"""
self._cli_popen(
- self.PATH_SLAPADD,
+ self.PATH_SLAPD,
stdin_data=ldif.encode("utf-8") if ldif else None,
extra_args=extra_args,
+ tool='add'
)
def __enter__(self):
@@ -576,7 +599,7 @@ def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
- self.stop()
+ self.stop(exc_type is None)
class SlapdTestCase(unittest.TestCase):
@@ -605,4 +628,4 @@ def setUpClass(cls):
@classmethod
def tearDownClass(cls):
- cls.server.stop()
+ cls.server.stop(False)
diff --git a/MANIFEST.in b/MANIFEST.in
index 687d2b0c..bedea8d6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,5 @@
include MANIFEST.in Makefile CHANGES INSTALL LICENCE README TODO
-include tox.ini .coveragerc
+include tox.ini
include Modules/*.c Modules/*.h
recursive-include Build *.cfg*
recursive-include Lib *.py
diff --git a/Makefile b/Makefile
index 2b52ddf5..da23b374 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,6 @@ Modules/constants_generated.h: Lib/ldap/constants.py
.PHONY: clean
clean:
rm -rf build dist *.egg-info .tox MANIFEST
- rm -f .coverage .coverage.*
find . \( -name '*.py[co]' -or -name '*.so*' -or -name '*.dylib' \) \
-delete
find . -depth -name __pycache__ -exec rm -rf {} \;
diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c
index 71fac73e..f96a6068 100644
--- a/Modules/LDAPObject.c
+++ b/Modules/LDAPObject.c
@@ -288,7 +288,10 @@ attrs_from_List(PyObject *attrlist, char ***attrsp)
if (seq == NULL)
goto error;
- len = PySequence_Length(attrlist);
+ len = PySequence_Size(seq);
+ if (len == -1) {
+ goto error;
+ }
attrs = PyMem_NEW(char *, len + 1);
@@ -1472,6 +1475,38 @@ l_ldap_extended_operation(LDAPObject *self, PyObject *args)
return PyLong_FromLong(msgid);
}
+/* ldap_connect */
+
+static PyObject *
+l_ldap_connect(LDAPObject *self, PyObject Py_UNUSED(args))
+{
+#if LDAP_VENDOR_VERSION >= 20500
+ int ldaperror;
+
+ if (ldap_version_info.ldapai_vendor_version < 20500)
+#endif
+ {
+ PyErr_SetString(PyExc_NotImplementedError,
+ "loaded libldap doesn't support this feature");
+ return NULL;
+ }
+
+#if LDAP_VENDOR_VERSION >= 20500
+ if (not_valid(self))
+ return NULL;
+
+ LDAP_BEGIN_ALLOW_THREADS(self);
+ ldaperror = ldap_connect(self->ldap);
+ LDAP_END_ALLOW_THREADS(self);
+
+ if ( ldaperror != LDAP_SUCCESS )
+ return LDAPerror(self->ldap);
+
+ Py_INCREF(Py_None);
+ return Py_None;
+#endif
+}
+
/* methods */
static PyMethodDef methods[] = {
@@ -1501,6 +1536,7 @@ static PyMethodDef methods[] = {
{"cancel", (PyCFunction)l_ldap_cancel, METH_VARARGS},
#endif
{"extop", (PyCFunction)l_ldap_extended_operation, METH_VARARGS},
+ {"connect", (PyCFunction)l_ldap_connect, METH_NOARGS},
{NULL, NULL}
};
diff --git a/Modules/constants.c b/Modules/constants.c
index f0a0da94..64804e8d 100644
--- a/Modules/constants.c
+++ b/Modules/constants.c
@@ -229,6 +229,14 @@ LDAPinit_constants(PyObject *m)
if (PyModule_AddIntConstant(m, "LIBLDAP_R", thread_safe) != 0)
return -1;
+ if (ldap_get_option(NULL, LDAP_OPT_API_INFO, &ldap_version_info) != LDAP_SUCCESS) {
+ PyErr_SetString(PyExc_ImportError, "unrecognised libldap version");
+ return -1;
+ }
+ if (PyModule_AddIntConstant(m, "_VENDOR_VERSION_RUNTIME",
+ ldap_version_info.ldapai_vendor_version ) != 0)
+ return -1;
+
/* Generated constants -- see Lib/ldap/constants.py */
#define add_err(n) do { \
diff --git a/Modules/functions.c b/Modules/functions.c
index f7d9cf37..3f7f7eca 100644
--- a/Modules/functions.c
+++ b/Modules/functions.c
@@ -12,7 +12,7 @@ l_ldap_initialize(PyObject *unused, PyObject *args)
int ret;
PyThreadState *save;
- if (!PyArg_ParseTuple(args, "s:initialize", &uri))
+ if (!PyArg_ParseTuple(args, "z:initialize", &uri))
return NULL;
save = PyEval_SaveThread();
@@ -155,6 +155,222 @@ l_ldap_str2dn(PyObject *unused, PyObject *args)
return result;
}
+/* ldap_dn2str */
+
+static void
+_free_dn_structure(LDAPDN dn)
+{
+ if (dn == NULL)
+ return;
+
+ for (LDAPRDN *rdn = dn; *rdn != NULL; rdn++) {
+ for (LDAPAVA **avap = *rdn; *avap != NULL; avap++) {
+ LDAPAVA *ava = *avap;
+
+ if (ava->la_attr.bv_val) {
+ free(ava->la_attr.bv_val);
+ }
+ if (ava->la_value.bv_val) {
+ free(ava->la_value.bv_val);
+ }
+ free(ava);
+ }
+ free(*rdn);
+ }
+ free(dn);
+}
+
+/*
+ * Convert a Python list-of-list-of-(str, str, int) into an LDAPDN and
+ * call ldap_dn2bv to build a DN string.
+ *
+ * Python signature: dn2str(dn: list[list[tuple[str, str, int]]], flags: int) -> str
+ * Returns the DN string on success, or raises TypeError or RuntimeError on error.
+ */
+static PyObject *
+l_ldap_dn2str(PyObject *self, PyObject *args)
+{
+ PyObject *dn_list = NULL;
+ int flags = 0;
+ LDAPDN dn = NULL;
+ LDAPAVA *ava;
+ LDAPAVA **rdn;
+ BerValue str = { 0, NULL };
+ PyObject *py_rdn_seq = NULL, *py_ava_item = NULL;
+ PyObject *py_name = NULL, *py_value = NULL, *py_encoding = NULL;
+ PyObject *result = NULL;
+ Py_ssize_t nrdns = 0, navas = 0, name_len = 0, value_len = 0;
+ int i = 0, j = 0;
+ int ldap_err;
+ const char *name_utf8, *value_utf8;
+
+ const char *type_error_message = "expected list[list[tuple[str, str, int]]]";
+
+ if (!PyArg_ParseTuple(args, "Oi:dn2str", &dn_list, &flags)) {
+ return NULL;
+ }
+
+ if (!PySequence_Check(dn_list)) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ return NULL;
+ }
+
+ nrdns = PySequence_Size(dn_list);
+ if (nrdns < 0) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ return NULL;
+ }
+
+ /* Allocate array of LDAPRDN pointers (+1 for NULL terminator) */
+ dn = (LDAPRDN *) calloc((size_t)nrdns + 1, sizeof(LDAPRDN));
+ if (dn == NULL) {
+ PyErr_NoMemory();
+ return NULL;
+ }
+
+ for (i = 0; i < nrdns; i++) {
+ py_rdn_seq = PySequence_GetItem(dn_list, i); /* New reference */
+ if (py_rdn_seq == NULL) {
+ goto error_cleanup;
+ }
+ if (!PySequence_Check(py_rdn_seq)) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ goto error_cleanup;
+ }
+
+ navas = PySequence_Size(py_rdn_seq);
+ if (navas < 0) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ goto error_cleanup;
+ }
+
+ /* Allocate array of LDAPAVA* pointers (+1 for NULL terminator) */
+ rdn = (LDAPAVA **)calloc((size_t)navas + 1, sizeof(LDAPAVA *));
+ if (rdn == NULL) {
+ PyErr_NoMemory();
+ goto error_cleanup;
+ }
+
+ for (j = 0; j < navas; j++) {
+ py_ava_item = PySequence_GetItem(py_rdn_seq, j); /* New reference */
+ if (py_ava_item == NULL) {
+ goto error_cleanup;
+ }
+ /* Expect a 3‐tuple: (name: str, value: str, encoding: int) */
+ if (!PyTuple_Check(py_ava_item) || PyTuple_Size(py_ava_item) != 3) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ goto error_cleanup;
+ }
+
+ py_name = PyTuple_GetItem(py_ava_item, 0); /* Borrowed reference */
+ py_value = PyTuple_GetItem(py_ava_item, 1); /* Borrowed reference */
+ py_encoding = PyTuple_GetItem(py_ava_item, 2); /* Borrowed reference */
+
+ if (!PyUnicode_Check(py_name) || !PyUnicode_Check(py_value) || !PyLong_Check(py_encoding)) {
+ PyErr_SetString(PyExc_TypeError, type_error_message);
+ goto error_cleanup;
+ }
+
+ name_len = 0;
+ value_len = 0;
+ name_utf8 = PyUnicode_AsUTF8AndSize(py_name, &name_len);
+ value_utf8 = PyUnicode_AsUTF8AndSize(py_value, &value_len);
+ if (name_utf8 == NULL || value_utf8 == NULL) {
+ goto error_cleanup;
+ }
+
+ ava = (LDAPAVA *) calloc(1, sizeof(LDAPAVA));
+
+ if (ava == NULL) {
+ PyErr_NoMemory();
+ goto error_cleanup;
+ }
+
+ ava->la_attr.bv_val = (char *)malloc((size_t)name_len + 1);
+ if (ava->la_attr.bv_val == NULL) {
+ free(ava);
+ PyErr_NoMemory();
+ goto error_cleanup;
+ }
+ memcpy(ava->la_attr.bv_val, name_utf8, (size_t)name_len);
+ ava->la_attr.bv_val[name_len] = '\0';
+ ava->la_attr.bv_len = (ber_len_t) name_len;
+
+ ava->la_value.bv_val = (char *)malloc((size_t)value_len + 1);
+ if (ava->la_value.bv_val == NULL) {
+ free(ava->la_attr.bv_val);
+ free(ava);
+ PyErr_NoMemory();
+ goto error_cleanup;
+ }
+ memcpy(ava->la_value.bv_val, value_utf8, (size_t)value_len);
+ ava->la_value.bv_val[value_len] = '\0';
+ ava->la_value.bv_len = (ber_len_t) value_len;
+
+ ava->la_flags = (int)PyLong_AsLong(py_encoding);
+ if (PyErr_Occurred()) {
+ /* Encoding conversion failed */
+ free(ava->la_attr.bv_val);
+ free(ava->la_value.bv_val);
+ free(ava);
+ goto error_cleanup;
+ }
+
+ rdn[j] = ava;
+ Py_DECREF(py_ava_item);
+ py_ava_item = NULL;
+ }
+
+ /* Null‐terminate the RDN */
+ rdn[navas] = NULL;
+
+ dn[i] = rdn;
+ Py_DECREF(py_rdn_seq);
+ py_rdn_seq = NULL;
+ }
+
+ /* Null‐terminate the DN */
+ dn[nrdns] = NULL;
+
+ /* Call ldap_dn2bv to build a DN string */
+ ldap_err = ldap_dn2bv(dn, &str, flags);
+ if (ldap_err != LDAP_SUCCESS) {
+ PyErr_SetString(PyExc_RuntimeError, ldap_err2string(ldap_err));
+ goto error_cleanup;
+ }
+
+ result = PyUnicode_FromString(str.bv_val);
+ if (result == NULL) {
+ goto error_cleanup;
+ }
+
+ /* Free the memory allocated by ldap_dn2bv */
+ ldap_memfree(str.bv_val);
+ str.bv_val = NULL;
+
+ /* Free our local DN structure */
+ _free_dn_structure(dn);
+ dn = NULL;
+
+ return result;
+
+ error_cleanup:
+ /* Free any partially built DN structure */
+ _free_dn_structure(dn);
+ dn = NULL;
+
+ /* If ldap_dn2bv allocated something, free it */
+ if (str.bv_val) {
+ ldap_memfree(str.bv_val);
+ str.bv_val = NULL;
+ }
+
+ /* Cleanup Python temporaries */
+ Py_XDECREF(py_ava_item);
+ Py_XDECREF(py_rdn_seq);
+ return NULL;
+}
+
/* ldap_set_option (global options) */
static PyObject *
@@ -191,6 +407,7 @@ static PyMethodDef methods[] = {
{"initialize_fd", (PyCFunction)l_ldap_initialize_fd, METH_VARARGS},
#endif
{"str2dn", (PyCFunction)l_ldap_str2dn, METH_VARARGS},
+ {"dn2str", (PyCFunction)l_ldap_dn2str, METH_VARARGS},
{"set_option", (PyCFunction)l_ldap_set_option, METH_VARARGS},
{"get_option", (PyCFunction)l_ldap_get_option, METH_VARARGS},
{NULL, NULL}
diff --git a/Modules/ldapmodule.c b/Modules/ldapmodule.c
index cb3f58fb..d0735356 100644
--- a/Modules/ldapmodule.c
+++ b/Modules/ldapmodule.c
@@ -5,6 +5,10 @@
#define _STR(x) #x
#define STR(x) _STR(x)
+LDAPAPIInfo ldap_version_info = {
+ .ldapai_info_version = LDAP_API_INFO_VERSION,
+};
+
static char version_str[] = STR(LDAPMODULE_VERSION);
static char author_str[] = STR(LDAPMODULE_AUTHOR);
static char license_str[] = STR(LDAPMODULE_LICENSE);
diff --git a/Modules/pythonldap.h b/Modules/pythonldap.h
index 7703af5e..35ed0d92 100644
--- a/Modules/pythonldap.h
+++ b/Modules/pythonldap.h
@@ -71,6 +71,8 @@ PYLDAP_FUNC(PyObject *) LDAPerror(LDAP *);
PYLDAP_FUNC(PyObject *) LDAPraise_for_message(LDAP *, LDAPMessage *m);
PYLDAP_FUNC(PyObject *) LDAPerr(int errnum);
+PYLDAP_DATA(LDAPAPIInfo) ldap_version_info;
+
#ifndef LDAP_CONTROL_PAGE_OID
#define LDAP_CONTROL_PAGE_OID "1.2.840.113556.1.4.319"
#endif /* !LDAP_CONTROL_PAGE_OID */
diff --git a/Tests/__init__.py b/Tests/__init__.py
index ea28d0ce..1a6a8836 100644
--- a/Tests/__init__.py
+++ b/Tests/__init__.py
@@ -21,3 +21,4 @@
from . import t_untested_mods
from . import t_ldap_controls_libldap
from . import t_ldap_options
+from . import t_ldap_syncrepl
diff --git a/Tests/t_cext.py b/Tests/t_cext.py
index 33fbf29a..846127f8 100644
--- a/Tests/t_cext.py
+++ b/Tests/t_cext.py
@@ -280,6 +280,29 @@ def test_simple_anonymous_bind(self):
self.assertEqual(pmsg, [])
self.assertEqual(ctrls, [])
+ @unittest.skipUnless(
+ _ldap.VENDOR_VERSION >= 20500 and \
+ _ldap._VENDOR_VERSION_RUNTIME >= 20500,
+ reason="Test requires libldap 2.5+"
+ )
+ def test_connect(self):
+ l = self._open_conn(bind=False)
+ invalid_fileno = l.get_option(_ldap.OPT_DESC)
+ l.connect()
+ fileno = l.get_option(_ldap.OPT_DESC)
+ self.assertNotEqual(invalid_fileno, fileno)
+
+ self._bind_conn(l)
+
+ @unittest.skipUnless(
+ _ldap._VENDOR_VERSION_RUNTIME < 20500,
+ reason="Test requires linking to libldap < 2.5"
+ )
+ def test_connect_notimpl(self):
+ l = self._open_conn(bind=False)
+ with self.assertRaises(NotImplementedError):
+ l.connect()
+
def test_anon_rootdse_search(self):
l = self._open_conn(bind=False)
# see if we can get the rootdse with anon search (without prior bind)
diff --git a/Tests/t_ldap_dn.py b/Tests/t_ldap_dn.py
index 86d36403..c4d9cb6c 100644
--- a/Tests/t_ldap_dn.py
+++ b/Tests/t_ldap_dn.py
@@ -40,23 +40,24 @@ def test_escape_dn_chars(self):
test function escape_dn_chars()
"""
self.assertEqual(ldap.dn.escape_dn_chars('foobar'), 'foobar')
- self.assertEqual(ldap.dn.escape_dn_chars('foo,bar'), 'foo\\,bar')
- self.assertEqual(ldap.dn.escape_dn_chars('foo=bar'), 'foo\\=bar')
+ self.assertEqual(ldap.dn.escape_dn_chars('foo,bar'), r'foo\,bar')
+ self.assertEqual(ldap.dn.escape_dn_chars('foo=bar'), r'foo\=bar')
self.assertEqual(ldap.dn.escape_dn_chars('foo#bar'), 'foo#bar')
- self.assertEqual(ldap.dn.escape_dn_chars('#foobar'), '\\#foobar')
+ self.assertEqual(ldap.dn.escape_dn_chars('#foobar'), r'\#foobar')
self.assertEqual(ldap.dn.escape_dn_chars('foo bar'), 'foo bar')
- self.assertEqual(ldap.dn.escape_dn_chars(' foobar'), '\\ foobar')
- self.assertEqual(ldap.dn.escape_dn_chars(' '), '\\ ')
- self.assertEqual(ldap.dn.escape_dn_chars(' '), '\\ \\ ')
- self.assertEqual(ldap.dn.escape_dn_chars('foobar '), 'foobar\\ ')
- self.assertEqual(ldap.dn.escape_dn_chars('f+o>o,bo\\,b\\o,bo\,b\=3.6"
+keywords = ["ldap", "directory", "authentication"]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Intended Audience :: System Administrators",
+ "Operating System :: OS Independent",
+ "Operating System :: MacOS :: MacOS X",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX",
+ "Programming Language :: C",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Database",
+ "Topic :: Internet",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP",
+ "License :: OSI Approved :: Python Software Foundation License",
+]
+dependencies = [
+ "pyasn1 >= 0.3.7",
+ "pyasn1_modules >= 0.1.5",
+]
+
+[project.urls]
+Homepage = "https://www.python-ldap.org/"
+Documentation = "https://python-ldap.readthedocs.io/"
+Repository = "https://github.com/python-ldap/python-ldap"
+Download = "https://pypi.org/project/python-ldap/"
+Changelog = "https://github.com/python-ldap/python-ldap/blob/main/CHANGES"
+
+
+
+[tool.setuptools]
+zip-safe = false
+include-package-data = true
+license-files = ["LICENCE", "LICENCE.MIT"]
+# Explicitly list all Python modules
+py-modules = ["ldapurl", "ldif"]
+
+[tool.setuptools.dynamic]
+version = {attr = "ldap.pkginfo.__version__"}
+
+[tool.setuptools.packages.find]
+where = ["Lib"]
+
+[tool.setuptools.package-dir]
+"" = "Lib"
[tool.isort]
-line_length=88
-known_first_party=['ldap', '_ldap', 'ldapurl', 'ldif', 'slapdtest']
-sections=['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER']
+line_length = 88
+known_first_party = ["ldap", "_ldap", "ldapurl", "ldif", "slapdtest"]
+sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
+
+[tool.coverage.run]
+branch = true
+source = [
+ "ldap",
+ "ldif",
+ "ldapurl",
+ "slapdtest",
+]
+
+[tool.coverage.paths]
+source = [
+ "Lib/",
+ ".tox/*/lib/python*/site-packages/",
+]
+
+[tool.coverage.report]
+ignore_errors = false
+precision = 1
+exclude_lines = [
+ "pragma: no cover",
+ "raise NotImplementedError",
+ "if 0:",
+ "if __name__ == .__main__.:",
+ "if PY2",
+ "if not PY2",
+]
+
+[tool.coverage.html]
+directory = "build/htmlcov"
+title = "python-ldap coverage report"
diff --git a/setup.py b/setup.py
index 8e7963a1..f2e816be 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,9 @@
"""
-setup.py - Setup package with the help Python's DistUtils
+setup.py - C extension module configuration for python-ldap
See https://www.python-ldap.org/ for details.
+This file handles only the C extension modules (_ldap) configuration,
+while pyproject.toml handles all project metadata, dependencies, and other settings.
"""
import sys,os
@@ -54,55 +56,8 @@ class OpenLDAP2:
LDAP_CLASS.extra_link_args.append('-pg')
LDAP_CLASS.libs.append('gcov')
-#-- Let distutils/setuptools do the rest
-name = 'python-ldap'
-
+#-- C extension modules configuration only
setup(
- #-- Package description
- name = name,
- license=pkginfo.__license__,
- version=pkginfo.__version__,
- description = 'Python modules for implementing LDAP clients',
- long_description = """python-ldap:
- python-ldap provides an object-oriented API to access LDAP directory servers
- from Python programs. Mainly it wraps the OpenLDAP 2.x libs for that purpose.
- Additionally the package contains modules for other LDAP-related stuff
- (e.g. processing LDIF, LDAPURLs, LDAPv3 schema, LDAPv3 extended operations
- and controls, etc.).
- """,
- author = 'python-ldap project',
- author_email = 'python-ldap@python.org',
- url = 'https://www.python-ldap.org/',
- download_url = 'https://pypi.org/project/python-ldap/',
- classifiers = [
- 'Development Status :: 5 - Production/Stable',
- 'Intended Audience :: Developers',
- 'Intended Audience :: System Administrators',
- 'Operating System :: OS Independent',
- 'Operating System :: MacOS :: MacOS X',
- 'Operating System :: Microsoft :: Windows',
- 'Operating System :: POSIX',
- 'Programming Language :: C',
-
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.8',
- 'Programming Language :: Python :: 3.9',
- 'Programming Language :: Python :: 3.10',
- 'Programming Language :: Python :: 3.11',
- 'Programming Language :: Python :: 3.12',
- 'Programming Language :: Python :: 3.13',
- # Note: when updating Python versions, also change tox.ini and .github/workflows/*
-
- 'Topic :: Database',
- 'Topic :: Internet',
- 'Topic :: Software Development :: Libraries :: Python Modules',
- 'Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP',
- 'License :: OSI Approved :: Python Software Foundation License',
- ],
- #-- C extension modules
ext_modules = [
Extension(
'_ldap',
@@ -138,28 +93,4 @@ class OpenLDAP2:
]
),
],
- #-- Python "stand alone" modules
- py_modules = [
- 'ldapurl',
- 'ldif',
-
- ],
- packages = [
- 'ldap',
- 'ldap.controls',
- 'ldap.extop',
- 'ldap.schema',
- 'slapdtest',
- 'slapdtest.certs',
- ],
- package_dir = {'': 'Lib',},
- data_files = LDAP_CLASS.extra_files,
- include_package_data=True,
- install_requires=[
- 'pyasn1 >= 0.3.7',
- 'pyasn1_modules >= 0.1.5',
- ],
- zip_safe=False,
- python_requires='>=3.6',
- test_suite = 'Tests',
)
diff --git a/tox.ini b/tox.ini
index 22752067..0741ef29 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,15 +5,12 @@
[tox]
# Note: when updating Python versions, also change setup.py and .github/worlflows/*
-envlist = py{36,37,38,39,310,311,312},c90-py{36,37},py3-nosasltls,doc,py3-trace,pypy3.9
+envlist = py{39,310,311,312},py3-nosasltls,doc,py3-trace,pypy3.9
minver = 1.8
[gh-actions]
python =
- 3.6: py36
- 3.7: py37
- 3.8: py38, doc, py3-nosasltls
- 3.9: py39, py3-trace
+ 3.9: py39, py3-trace, doc, py3-nosasltls
3.10: py310
3.11: py311
3.12: py312
@@ -36,6 +33,7 @@ commands = {envpython} -bb -Werror \
setenv =
CFLAGS=-Wno-int-in-bool-context -Werror -std=c99
+
[testenv:py3-nosasltls]
basepython = python3
# don't install, install dependencies manually