New Upstream Release - python-boltons

Ready changes

Summary

Merged new upstream version: 23.0.0 (was: 21.0.0).

Resulting package

Built on 2023-03-10T22:38 (took 2m3s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-boltons

Lintian Result

Diff

diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 69a6313..7183dc7 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -18,18 +18,18 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - {name: Linux, python: '3.9', os: ubuntu-latest, tox: py39}
-          - {name: Windows, python: '3.9', os: windows-latest, tox: py39}
-          - {name: Mac, python: '3.9', os: macos-latest, tox: py39}
+          - {name: Linux, python: '3.11', os: ubuntu-latest, tox: py311}
+          - {name: Windows, python: '3.11', os: windows-latest, tox: py311}
+          - {name: Mac, python: '3.11', os: macos-latest, tox: py311}
+          - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310}
+          - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39}
           - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38}
           - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37}
-          - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36}
-          - {name: '2.7', python: '2.7', os: ubuntu-latest, tox: py27}
-          - {name: 'PyPy2', python: 'pypy2', os: ubuntu-latest, tox: pypy}
-          - {name: 'PyPy3', python: 'pypy3', os: ubuntu-latest, tox: pypy3}
+          - {name: '2.7', python: '2.7.18', os: ubuntu-latest, tox: py27}
+          - {name: 'PyPy3', python: 'pypy-3.9', os: ubuntu-latest, tox: pypy3}
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
       - name: update pip
diff --git a/.gitignore b/.gitignore
index 604a8e8..c6c4daf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
 docs/_build
 tmp.py
 htmlcov/
-
+venv/
 *.py[cod]
 
 # emacs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a1a61f..7ff4e32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ for an average of one 33-commit release about every 9 weeks. Versions
 are named according to the [CalVer](https://calver.org) versioning
 scheme (`YY.MINOR.MICRO`).
 
+23.0.0
+------
+*(February 19, 2023)*
+
+* Overdue update for Python 3.10 and 3.11 support ([#294][i294], [#303][i303], [#320][i320], [#323][i323], [#326][i326]/[#327][i327])
+* Add [iterutils.chunk_ranges][iterutils.chunk_ranges] ([#312][i312])
+* Improvements to `SpooledBytesIO`/`SpooledStringIO` ([#305][i305])
+* Bugfix for infinite daterange issue when start and stop is the same ([#302][i302])
+* Fix `Bits.as_list` behavior ([#315][i315])
+
 21.0.0
 ------
 *(May 16, 2021)*
@@ -1027,6 +1037,16 @@ added in this release.
 [i161]: https://github.com/mahmoud/boltons/issues/161
 [i162]: https://github.com/mahmoud/boltons/issues/162
 [i164]: https://github.com/mahmoud/boltons/issues/164
+[i294]: https://github.com/mahmoud/boltons/issues/294
+[i302]: https://github.com/mahmoud/boltons/issues/302
+[i303]: https://github.com/mahmoud/boltons/issues/303
+[i305]: https://github.com/mahmoud/boltons/issues/305
+[i312]: https://github.com/mahmoud/boltons/issues/312
+[i315]: https://github.com/mahmoud/boltons/issues/315
+[i320]: https://github.com/mahmoud/boltons/issues/320
+[i323]: https://github.com/mahmoud/boltons/issues/323
+[i326]: https://github.com/mahmoud/boltons/issues/326
+[i327]: https://github.com/mahmoud/boltons/issues/327
 [ioutils]: http://boltons.readthedocs.org/en/latest/ioutils.html
 [ioutils.MultiFileReader]: http://boltons.readthedocs.org/en/latest/ioutils.html#boltons.ioutils.MultiFileReader
 [ioutils.SpooledBytesIO]: http://boltons.readthedocs.org/en/latest/ioutils.html#boltons.ioutils.SpooledBytesIO
@@ -1036,6 +1056,7 @@ added in this release.
 [iterutils.backoff_iter]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.backoff_iter
 [iterutils.chunked]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.chunked
 [iterutils.chunked_iter]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.chunked_iter
+[iterutils.chunk_ranges]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.chunk_ranges
 [iterutils.first]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.first
 [iterutils.flatten]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.flatten
 [iterutils.flatten_iter]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.flatten_iter
diff --git a/README.md b/README.md
index 082cd7c..665e225 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
 <a href="https://boltons.readthedocs.io/en/latest/"><img src="https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat"></a>
 <a href="https://pypi.python.org/pypi/boltons"><img src="https://img.shields.io/pypi/v/boltons.svg"></a>
 <a href="https://anaconda.org/conda-forge/boltons"><img src="https://img.shields.io/conda/vn/conda-forge/boltons.svg"></a>
-<a href="https://ports.macports.org/port/py-boltons/summary"><img src="https://img.shields.io/badge/macports-v20.2.1-blue?logo=Apple&logoColor=white"></a>
+<a href="https://ports.macports.org/port/py-boltons/summary"><img src="https://repology.org/badge/version-for-repo/macports/python:boltons.svg?header=🍎 MacPorts"></a>
 <a href="https://pypi.python.org/pypi/boltons"><img src="https://img.shields.io/pypi/pyversions/boltons.svg"></a>
 <a href="http://calver.org"><img src="https://img.shields.io/badge/calver-YY.MINOR.MICRO-22bfda.svg"></a>
 
diff --git a/boltons/cacheutils.py b/boltons/cacheutils.py
index 4e5e508..27a19cb 100644
--- a/boltons/cacheutils.py
+++ b/boltons/cacheutils.py
@@ -82,7 +82,7 @@ except Exception:
             pass
 
 try:
-    from boltons.typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
     _KWARG_MARK = make_sentinel(var_name='_KWARG_MARK')
 except ImportError:
diff --git a/boltons/debugutils.py b/boltons/debugutils.py
index 772f231..aca42d5 100644
--- a/boltons/debugutils.py
+++ b/boltons/debugutils.py
@@ -47,7 +47,7 @@ except NameError:
     from reprlib import Repr
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _UNSET = make_sentinel(var_name='_UNSET')
 except ImportError:
     _UNSET = object()
diff --git a/boltons/dictutils.py b/boltons/dictutils.py
index 2954bba..31a1951 100644
--- a/boltons/dictutils.py
+++ b/boltons/dictutils.py
@@ -82,7 +82,7 @@ except ImportError:
     from itertools import zip_longest as izip_longest
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
 except ImportError:
     _MISSING = object()
diff --git a/boltons/ecoutils.py b/boltons/ecoutils.py
index 0ccad70..91b9412 100644
--- a/boltons/ecoutils.py
+++ b/boltons/ecoutils.py
@@ -354,38 +354,53 @@ def get_profile(**kwargs):
     return ret
 
 
-_real_safe_repr = pprint._safe_repr
-
-
-def _fake_json_dumps(val, indent=2):
-    # never do this. this is a hack for Python 2.4. Python 2.5 added
-    # the json module for a reason.
-    def _fake_safe_repr(*a, **kw):
-        res, is_read, is_rec = _real_safe_repr(*a, **kw)
-        if res == 'None':
-            res = 'null'
-        if res == 'True':
-            res = 'true'
-        if res == 'False':
-            res = 'false'
-        if not (res.startswith("'") or res.startswith("u'")):
-            res = res
-        else:
-            if res.startswith('u'):
-                res = res[1:]
+try:
+    import json
+
+    def dumps(val, indent):
+        if indent:
+            return json.dumps(val, sort_keys=True, indent=indent)
+        return json.dumps(val, sort_keys=True)
+
+except ImportError:
+    _real_safe_repr = pprint._safe_repr
+
+    def _fake_json_dumps(val, indent=2):
+        # never do this. this is a hack for Python 2.4. Python 2.5 added
+        # the json module for a reason.
+        def _fake_safe_repr(*a, **kw):
+            res, is_read, is_rec = _real_safe_repr(*a, **kw)
+            if res == 'None':
+                res = 'null'
+            if res == 'True':
+                res = 'true'
+            if res == 'False':
+                res = 'false'
+            if not (res.startswith("'") or res.startswith("u'")):
+                res = res
+            else:
+                if res.startswith('u'):
+                    res = res[1:]
 
-            contents = res[1:-1]
-            contents = contents.replace('"', '').replace(r'\"', '')
-            res = '"' + contents + '"'
-        return res, is_read, is_rec
+                contents = res[1:-1]
+                contents = contents.replace('"', '').replace(r'\"', '')
+                res = '"' + contents + '"'
+            return res, is_read, is_rec
 
-    pprint._safe_repr = _fake_safe_repr
-    try:
-        ret = pprint.pformat(val, indent=indent)
-    finally:
-        pprint._safe_repr = _real_safe_repr
+        pprint._safe_repr = _fake_safe_repr
+        try:
+            ret = pprint.pformat(val, indent=indent)
+        finally:
+            pprint._safe_repr = _real_safe_repr
+
+        return ret
+
+    def dumps(val, indent):
+        ret = _fake_json_dumps(val, indent=indent)
+        if not indent:
+            ret = re.sub(r'\n\s*', ' ', ret)
+        return ret
 
-    return ret
 
 
 def get_profile_json(indent=False):
@@ -393,20 +408,6 @@ def get_profile_json(indent=False):
         indent = 2
     else:
         indent = 0
-    try:
-        import json
-
-        def dumps(val, indent):
-            if indent:
-                return json.dumps(val, sort_keys=True, indent=indent)
-            return json.dumps(val, sort_keys=True)
-
-    except ImportError:
-        def dumps(val, indent):
-            ret = _fake_json_dumps(val, indent=indent)
-            if not indent:
-                ret = re.sub(r'\n\s*', ' ', ret)
-            return ret
 
     data_dict = get_profile()
     return dumps(data_dict, indent)
diff --git a/boltons/formatutils.py b/boltons/formatutils.py
index f77b3ee..a45adb1 100644
--- a/boltons/formatutils.py
+++ b/boltons/formatutils.py
@@ -54,7 +54,7 @@ format strings:
     arguments out of a format string.
   * :func:`tokenize_format_str`: Tokenize a format string into
     literals and :class:`BaseFormatField` objects.
-  * :func:`construct_format_field_str`: Assists in progammatic
+  * :func:`construct_format_field_str`: Assists in programmatic
     construction of format strings.
   * :func:`infer_positional_format_args`: Converts anonymous
     references in 2.7+ format strings to explicit positional arguments
diff --git a/boltons/funcutils.py b/boltons/funcutils.py
index 40befee..e46cddc 100644
--- a/boltons/funcutils.py
+++ b/boltons/funcutils.py
@@ -64,7 +64,7 @@ except AttributeError:
 
 
 try:
-    from boltons.typeutils import make_sentinel
+    from .typeutils import make_sentinel
     NO_DEFAULT = make_sentinel(var_name='NO_DEFAULT')
 except ImportError:
     NO_DEFAULT = object()
@@ -588,7 +588,7 @@ def update_wrapper(wrapper, func, injected=None, expected=None, build_from=None,
             in the updated function.
 
     In opposition to the built-in :func:`functools.update_wrapper` bolton's
-    version returns a copy of the function and does not modifiy anything in place.
+    version returns a copy of the function and does not modify anything in place.
     For more in-depth wrapping of functions, see the
     :class:`FunctionBuilder` type, on which update_wrapper was built.
     """
diff --git a/boltons/ioutils.py b/boltons/ioutils.py
index ef3947e..dd67aaf 100644
--- a/boltons/ioutils.py
+++ b/boltons/ioutils.py
@@ -40,7 +40,7 @@ are useful when dealing with input, output, and bytestreams in a variety of
 ways.
 """
 import os
-from io import BytesIO
+from io import BytesIO, IOBase
 from abc import (
     ABCMeta,
     abstractmethod,
@@ -50,6 +50,11 @@ from errno import EINVAL
 from codecs import EncodedFile
 from tempfile import TemporaryFile
 
+try:
+    from itertools import izip_longest as zip_longest # Python 2
+except ImportError:
+    from itertools import zip_longest  # Python 3
+
 try:
     text_type = unicode  # Python 2
     binary_type = str
@@ -66,16 +71,14 @@ value.
 """
 
 
-class SpooledIOBase(object):
+class SpooledIOBase(IOBase):
     """
-    The SpooledTempoaryFile class doesn't support a number of attributes and
-    methods that a StringIO instance does. This brings the api as close to
-    compatible as possible with StringIO so that it may be used as a near
-    drop-in replacement to save memory.
-
-    Another issue with SpooledTemporaryFile is that the spooled file is always
-    a cStringIO rather than a StringIO which causes issues with some of our
-    tools.
+    A base class shared by the SpooledBytesIO and SpooledStringIO classes.
+
+    The SpooledTemporaryFile class is missing several attributes and methods
+    present in the StringIO implementation. This brings the api as close to
+    parity as possible so that classes derived from SpooledIOBase can be used
+    as near drop-in replacements to save memory.
     """
     __metaclass__ = ABCMeta
 
@@ -83,6 +86,11 @@ class SpooledIOBase(object):
         self._max_size = max_size
         self._dir = dir
 
+    def _checkClosed(self, msg=None):
+        """Raise a ValueError if file is closed"""
+        if self.closed:
+            raise ValueError('I/O operation on closed file.'
+                             if msg is None else msg)
     @abstractmethod
     def read(self, n=-1):
         """Read n characters from the buffer"""
@@ -103,6 +111,16 @@ class SpooledIOBase(object):
     def readlines(self, sizehint=0):
         """Returns a list of all lines from the current position forward"""
 
+    def writelines(self, lines):
+        """
+        Write lines to the file from an interable.
+
+        NOTE: writelines() does NOT add line separators.
+        """
+        self._checkClosed()
+        for line in lines:
+            self.write(line)
+
     @abstractmethod
     def rollover(self):
         """Roll file-like-object over into a real temporary file"""
@@ -139,22 +157,13 @@ class SpooledIOBase(object):
         return self.buffer.close()
 
     def flush(self):
+        self._checkClosed()
         return self.buffer.flush()
 
     def isatty(self):
+        self._checkClosed()
         return self.buffer.isatty()
 
-    def next(self):
-        line = self.readline()
-        if not line:
-            pos = self.buffer.tell()
-            self.buffer.seek(0, os.SEEK_END)
-            if pos == self.buffer.tell():
-                raise StopIteration
-            else:
-                self.buffer.seek(pos)
-        return line
-
     @property
     def closed(self):
         return self.buffer.closed
@@ -173,10 +182,13 @@ class SpooledIOBase(object):
 
     def truncate(self, size=None):
         """
+        Truncate the contents of the buffer.
+
         Custom version of truncate that takes either no arguments (like the
         real SpooledTemporaryFile) or a single argument that truncates the
         value to a certain index location.
         """
+        self._checkClosed()
         if size is None:
             return self.buffer.truncate()
 
@@ -191,7 +203,8 @@ class SpooledIOBase(object):
             self.seek(pos)
 
     def getvalue(self):
-        """Return the entire files contents"""
+        """Return the entire files contents."""
+        self._checkClosed()
         pos = self.tell()
         self.seek(0)
         val = self.read()
@@ -207,15 +220,29 @@ class SpooledIOBase(object):
     def writable(self):
         return True
 
-    __next__ = next
+    def __next__(self):
+        self._checkClosed()
+        line = self.readline()
+        if not line:
+            pos = self.buffer.tell()
+            self.buffer.seek(0, os.SEEK_END)
+            if pos == self.buffer.tell():
+                raise StopIteration
+            else:
+                self.buffer.seek(pos)
+        return line
+
+    next = __next__
 
     def __len__(self):
         return self.len
 
     def __iter__(self):
+        self._checkClosed()
         return self
 
     def __enter__(self):
+        self._checkClosed()
         return self
 
     def __exit__(self, *args):
@@ -223,7 +250,31 @@ class SpooledIOBase(object):
 
     def __eq__(self, other):
         if isinstance(other, self.__class__):
-            return self.getvalue() == other.getvalue()
+            self_pos = self.tell()
+            other_pos = other.tell()
+            try:
+                self.seek(0)
+                other.seek(0)
+                eq = True
+                for self_line, other_line in zip_longest(self, other):
+                    if self_line != other_line:
+                        eq = False
+                        break
+                self.seek(self_pos)
+                other.seek(other_pos)
+            except Exception:
+                # Attempt to return files to original position if there were any errors
+                try:
+                    self.seek(self_pos)
+                except Exception:
+                    pass
+                try:
+                    other.seek(other_pos)
+                except Exception:
+                    pass
+                raise
+            else:
+                return eq
         return False
 
     def __ne__(self, other):
@@ -232,6 +283,13 @@ class SpooledIOBase(object):
     def __bool__(self):
         return True
 
+    def __del__(self):
+        """Can fail when called at program exit so suppress traceback."""
+        try:
+            self.close()
+        except Exception:
+            pass
+
     __nonzero__ = __bool__
 
 
@@ -253,11 +311,13 @@ class SpooledBytesIO(SpooledIOBase):
     """
 
     def read(self, n=-1):
+        self._checkClosed()
         return self.buffer.read(n)
 
     def write(self, s):
+        self._checkClosed()
         if not isinstance(s, binary_type):
-            raise TypeError("{0} expected, got {1}".format(
+            raise TypeError("{} expected, got {}".format(
                 binary_type.__name__,
                 type(s).__name__
             ))
@@ -267,9 +327,11 @@ class SpooledBytesIO(SpooledIOBase):
         self.buffer.write(s)
 
     def seek(self, pos, mode=0):
+        self._checkClosed()
         return self.buffer.seek(pos, mode)
 
     def readline(self, length=None):
+        self._checkClosed()
         if length:
             return self.buffer.readline(length)
         else:
@@ -314,6 +376,7 @@ class SpooledBytesIO(SpooledIOBase):
         return val
 
     def tell(self):
+        self._checkClosed()
         return self.buffer.tell()
 
 
@@ -340,13 +403,15 @@ class SpooledStringIO(SpooledIOBase):
         super(SpooledStringIO, self).__init__(*args, **kwargs)
 
     def read(self, n=-1):
+        self._checkClosed()
         ret = self.buffer.reader.read(n, n)
         self._tell = self.tell() + len(ret)
         return ret
 
     def write(self, s):
+        self._checkClosed()
         if not isinstance(s, text_type):
-            raise TypeError("{0} expected, got {1}".format(
+            raise TypeError("{} expected, got {}".format(
                 text_type.__name__,
                 type(s).__name__
             ))
@@ -384,6 +449,7 @@ class SpooledStringIO(SpooledIOBase):
 
     def seek(self, pos, mode=0):
         """Traverse from offset to the specified codepoint"""
+        self._checkClosed()
         # Seek to position from the start of the file
         if mode == os.SEEK_SET:
             self.buffer.seek(0)
@@ -406,6 +472,7 @@ class SpooledStringIO(SpooledIOBase):
         return self.tell()
 
     def readline(self, length=None):
+        self._checkClosed()
         ret = self.buffer.readline(length).decode('utf-8')
         self._tell = self.tell() + len(ret)
         return ret
@@ -428,7 +495,7 @@ class SpooledStringIO(SpooledIOBase):
         return not isinstance(self.buffer.stream, BytesIO)
 
     def rollover(self):
-        """Roll the StringIO over to a TempFile"""
+        """Roll the buffer over to a TempFile"""
         if not self._rolled:
             tmp = EncodedFile(TemporaryFile(dir=self._dir),
                               data_encoding='utf-8')
@@ -440,6 +507,7 @@ class SpooledStringIO(SpooledIOBase):
 
     def tell(self):
         """Return the codepoint position"""
+        self._checkClosed()
         return self._tell
 
     @property
diff --git a/boltons/iterutils.py b/boltons/iterutils.py
index a485faf..eddbeff 100644
--- a/boltons/iterutils.py
+++ b/boltons/iterutils.py
@@ -55,7 +55,7 @@ except ImportError:
 
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _UNSET = make_sentinel('_UNSET')
     _REMAP_EXIT = make_sentinel('_REMAP_EXIT')
 except ImportError:
@@ -208,7 +208,7 @@ def split_iter(src, sep=None, maxsplit=None):
 
 def lstrip(iterable, strip_value=None):
     """Strips values from the beginning of an iterable. Stripped items will
-    match the value of the argument strip_value. Functionality is analigous
+    match the value of the argument strip_value. Functionality is analogous
     to that of the method str.lstrip. Returns a list.
 
     >>> lstrip(['Foo', 'Bar', 'Bam'], 'Foo')
@@ -220,7 +220,7 @@ def lstrip(iterable, strip_value=None):
 
 def lstrip_iter(iterable, strip_value=None):
     """Strips values from the beginning of an iterable. Stripped items will
-    match the value of the argument strip_value. Functionality is analigous
+    match the value of the argument strip_value. Functionality is analogous
     to that of the method str.lstrip. Returns a generator.
 
     >>> list(lstrip_iter(['Foo', 'Bar', 'Bam'], 'Foo'))
@@ -238,7 +238,7 @@ def lstrip_iter(iterable, strip_value=None):
 
 def rstrip(iterable, strip_value=None):
     """Strips values from the end of an iterable. Stripped items will
-    match the value of the argument strip_value. Functionality is analigous
+    match the value of the argument strip_value. Functionality is analogous
     to that of the method str.rstrip. Returns a list.
 
     >>> rstrip(['Foo', 'Bar', 'Bam'], 'Bam')
@@ -250,7 +250,7 @@ def rstrip(iterable, strip_value=None):
 
 def rstrip_iter(iterable, strip_value=None):
     """Strips values from the end of an iterable. Stripped items will
-    match the value of the argument strip_value. Functionality is analigous
+    match the value of the argument strip_value. Functionality is analogous
     to that of the method str.rstrip. Returns a generator.
 
     >>> list(rstrip_iter(['Foo', 'Bar', 'Bam'], 'Bam'))
@@ -279,7 +279,7 @@ def rstrip_iter(iterable, strip_value=None):
 def strip(iterable, strip_value=None):
     """Strips values from the beginning and end of an iterable. Stripped items
     will match the value of the argument strip_value. Functionality is
-    analigous to that of the method str.strip. Returns a list.
+    analogous to that of the method str.strip. Returns a list.
 
     >>> strip(['Fu', 'Foo', 'Bar', 'Bam', 'Fu'], 'Fu')
     ['Foo', 'Bar', 'Bam']
@@ -291,7 +291,7 @@ def strip(iterable, strip_value=None):
 def strip_iter(iterable,strip_value=None):
     """Strips values from the beginning and end of an iterable. Stripped items
     will match the value of the argument strip_value. Functionality is
-    analigous to that of the method str.strip. Returns a generator.
+    analogous to that of the method str.strip. Returns a generator.
 
     >>> list(strip_iter(['Fu', 'Foo', 'Bar', 'Bam', 'Fu'], 'Fu'))
     ['Foo', 'Bar', 'Bam']
@@ -323,6 +323,13 @@ def chunked(src, size, count=None, **kw):
         return list(itertools.islice(chunk_iter, count))
 
 
+def _validate_positive_int(value, name, strictly_positive=True):
+    value = int(value)
+    if value < 0 or (strictly_positive and value == 0):
+        raise ValueError('expected a positive integer ' + name)
+    return value
+
+
 def chunked_iter(src, size, **kw):
     """Generates *size*-sized chunks from *src* iterable. Unless the
     optional *fill* keyword argument is provided, iterables not evenly
@@ -339,9 +346,7 @@ def chunked_iter(src, size, **kw):
     # TODO: add count kwarg?
     if not is_iterable(src):
         raise TypeError('expected an iterable')
-    size = int(size)
-    if size <= 0:
-        raise ValueError('expected a positive integer chunk size')
+    size = _validate_positive_int(size, 'chunk size')
     do_fill = True
     try:
         fill_val = kw.pop('fill')
@@ -369,6 +374,56 @@ def chunked_iter(src, size, **kw):
     return
 
 
+def chunk_ranges(input_size, chunk_size, input_offset=0, overlap_size=0, align=False):
+    """Generates *chunk_size*-sized chunk ranges for an input with length *input_size*.
+    Optionally, a start of the input can be set via *input_offset*, and
+    and overlap between the chunks may be specified via *overlap_size*.
+    Also, if *align* is set to *True*, any items with *i % (chunk_size-overlap_size) == 0*
+    are always at the beginning of the chunk.
+
+    Returns an iterator of (start, end) tuples, one tuple per chunk.
+
+    >>> list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5))
+    [(10, 15), (15, 20)]
+    >>> list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5, overlap_size=1))
+    [(10, 15), (14, 19), (18, 20)]
+    >>> list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5, overlap_size=2))
+    [(10, 15), (13, 18), (16, 20)]
+
+    >>> list(chunk_ranges(input_offset=4, input_size=15, chunk_size=5, align=False))
+    [(4, 9), (9, 14), (14, 19)]
+    >>> list(chunk_ranges(input_offset=4, input_size=15, chunk_size=5, align=True))
+    [(4, 5), (5, 10), (10, 15), (15, 19)]
+
+    >>> list(chunk_ranges(input_offset=2, input_size=15, chunk_size=5, overlap_size=1, align=False))
+    [(2, 7), (6, 11), (10, 15), (14, 17)]
+    >>> list(chunk_ranges(input_offset=2, input_size=15, chunk_size=5, overlap_size=1, align=True))
+    [(2, 5), (4, 9), (8, 13), (12, 17)]
+    >>> list(chunk_ranges(input_offset=3, input_size=15, chunk_size=5, overlap_size=1, align=True))
+    [(3, 5), (4, 9), (8, 13), (12, 17), (16, 18)]
+    """
+    input_size = _validate_positive_int(input_size, 'input_size', strictly_positive=False)
+    chunk_size = _validate_positive_int(chunk_size, 'chunk_size')
+    input_offset = _validate_positive_int(input_offset, 'input_offset', strictly_positive=False)
+    overlap_size = _validate_positive_int(overlap_size, 'overlap_size', strictly_positive=False)
+
+    input_stop = input_offset + input_size
+
+    if align:
+        initial_chunk_len = chunk_size - input_offset % (chunk_size - overlap_size)
+        if initial_chunk_len != overlap_size:
+            yield (input_offset, min(input_offset + initial_chunk_len, input_stop))
+            if input_offset + initial_chunk_len >= input_stop:
+                return
+            input_offset = input_offset + initial_chunk_len - overlap_size
+
+    for i in range(input_offset, input_stop, chunk_size - overlap_size):
+        yield (i, min(i + chunk_size, input_stop))
+
+        if i + chunk_size >= input_stop:
+            return
+
+
 def pairwise(src):
     """Convenience function for calling :func:`windowed` on *src*, with
     *size* set to 2.
@@ -981,7 +1036,7 @@ def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,
           **kwargs):
     """The remap ("recursive map") function is used to traverse and
     transform nested structures. Lists, tuples, sets, and dictionaries
-    are just a few of the data structures nested into heterogenous
+    are just a few of the data structures nested into heterogeneous
     tree-like structures that are so common in programming.
     Unfortunately, Python's built-in ways to manipulate collections
     are almost all flat. List comprehensions may be fast and succinct,
@@ -1451,7 +1506,7 @@ def soft_sorted(iterable, first=None, last=None, key=None, reverse=False):
 
 def untyped_sorted(iterable, key=None, reverse=False):
     """A version of :func:`sorted` which will happily sort an iterable of
-    heterogenous types and return a new list, similar to legacy Python's
+    heterogeneous types and return a new list, similar to legacy Python's
     behavior.
 
     >>> untyped_sorted(['abc', 2.0, 1, 2, 'def'])
diff --git a/boltons/listutils.py b/boltons/listutils.py
index 290ace1..e1bd3cd 100644
--- a/boltons/listutils.py
+++ b/boltons/listutils.py
@@ -47,7 +47,7 @@ from math import log as math_log
 from itertools import chain, islice
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
 except ImportError:
     _MISSING = object()
diff --git a/boltons/mathutils.py b/boltons/mathutils.py
index 145afb6..bf7e95e 100644
--- a/boltons/mathutils.py
+++ b/boltons/mathutils.py
@@ -215,7 +215,7 @@ class Bits(object):
         return hash(self.val)
 
     def as_list(self):
-        return [c == '1' for c in '{0:b}'.format(self.val)]
+        return [c == '1' for c in self.as_bin()]
 
     def as_bin(self):
         return '{{0:0{0}b}}'.format(self.len).format(self.val)
diff --git a/boltons/queueutils.py b/boltons/queueutils.py
index 42adb14..56bf591 100644
--- a/boltons/queueutils.py
+++ b/boltons/queueutils.py
@@ -70,13 +70,13 @@ from bisect import insort
 import itertools
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _REMOVED = make_sentinel(var_name='_REMOVED')
 except ImportError:
     _REMOVED = object()
 
 try:
-    from listutils import BList
+    from .listutils import BList
     # see BarrelList docstring for notes
 except ImportError:
     BList = list
diff --git a/boltons/setutils.py b/boltons/setutils.py
index 16e5d74..5ee697e 100644
--- a/boltons/setutils.py
+++ b/boltons/setutils.py
@@ -53,7 +53,7 @@ except ImportError:
     from collections import MutableSet
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
 except ImportError:
     _MISSING = object()
diff --git a/boltons/socketutils.py b/boltons/socketutils.py
index d1c7233..4944227 100644
--- a/boltons/socketutils.py
+++ b/boltons/socketutils.py
@@ -82,7 +82,7 @@ except Exception:
 
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _UNSET = make_sentinel(var_name='_UNSET')
 except ImportError:
     _UNSET = object()
diff --git a/boltons/strutils.py b/boltons/strutils.py
index f94aefb..5b5b347 100644
--- a/boltons/strutils.py
+++ b/boltons/strutils.py
@@ -1176,7 +1176,7 @@ class MultiReplace(object):
 
     Dictionary Usage::
 
-        from lrmslib import stringutils
+        from boltons import stringutils
         s = stringutils.MultiReplace({
             'foo': 'zoo',
             'cat': 'hat',
@@ -1187,7 +1187,7 @@ class MultiReplace(object):
 
     Iterable Usage::
 
-        from lrmslib import stringutils
+        from boltons import stringutils
         s = stringutils.MultiReplace([
             ('foo', 'zoo'),
             ('cat', 'hat'),
@@ -1239,10 +1239,7 @@ class MultiReplace(object):
             else:
                 exp = vals[0].pattern
 
-            regex_values.append('(?P<{0}>{1})'.format(
-                group_name,
-                exp
-            ))
+            regex_values.append('(?P<{}>{})'.format(group_name, exp))
             self.group_map[group_name] = vals[1]
 
         self.combined_pattern = re.compile(
@@ -1267,7 +1264,18 @@ class MultiReplace(object):
 
 
 def multi_replace(text, sub_map, **kwargs):
-    """Shortcut function to invoke MultiReplace in a single call."""
+    """
+    Shortcut function to invoke MultiReplace in a single call.
+
+    Example Usage::
+
+        from boltons.stringutils import multi_replace
+        new = multi_replace(
+            'The foo bar cat ate a bat',
+            {'foo': 'zoo', 'cat': 'hat', 'bat': 'kraken'}
+        )
+        new == 'The zoo bar hat ate a kraken'
+    """
     m = MultiReplace(sub_map, **kwargs)
     return m.sub(text)
 
diff --git a/boltons/tableutils.py b/boltons/tableutils.py
index 13234a5..22c896f 100644
--- a/boltons/tableutils.py
+++ b/boltons/tableutils.py
@@ -72,7 +72,7 @@ except NameError:
     from html import escape as html_escape
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
 except ImportError:
     _MISSING = object()
diff --git a/boltons/timeutils.py b/boltons/timeutils.py
index 8d360d0..65699dc 100644
--- a/boltons/timeutils.py
+++ b/boltons/timeutils.py
@@ -377,10 +377,12 @@ def daterange(start, stop, step=1, inclusive=False):
     else:
         raise ValueError('step expected int, timedelta, or tuple'
                          ' (year, month, day), not: %r' % step)
+    
+    m_step += y_step * 12
 
     if stop is None:
         finished = lambda now, stop: False
-    elif start < stop:
+    elif start <= stop:
         finished = operator.gt if inclusive else operator.ge
     else:
         finished = operator.lt if inclusive else operator.le
@@ -388,10 +390,10 @@ def daterange(start, stop, step=1, inclusive=False):
 
     while not finished(now, stop):
         yield now
-        if y_step or m_step:
-            m_y_step, cur_month = divmod(now.month + m_step, 12)
-            now = now.replace(year=now.year + y_step + m_y_step,
-                              month=cur_month or 12)
+        if m_step:
+            m_y_step, cur_month = divmod((now.month - 1) + m_step, 12)
+            now = now.replace(year=now.year + m_y_step,
+                              month=(cur_month + 1))
         now = now + d_step
     return
 
diff --git a/boltons/urlutils.py b/boltons/urlutils.py
index 6016e8a..f21e505 100644
--- a/boltons/urlutils.py
+++ b/boltons/urlutils.py
@@ -685,7 +685,8 @@ class URL(object):
             if dest.path.startswith(u'/'):   # absolute path
                 new_path_parts = list(dest.path_parts)
             else:  # relative path
-                new_path_parts = self.path_parts[:-1] + dest.path_parts
+                new_path_parts = list(self.path_parts[:-1]) \
+                               + list(dest.path_parts)
         else:
             new_path_parts = list(self.path_parts)
             if not query_params:
@@ -994,7 +995,7 @@ except ImportError:
     from itertools import zip_longest as izip_longest
 
 try:
-    from typeutils import make_sentinel
+    from .typeutils import make_sentinel
     _MISSING = make_sentinel(var_name='_MISSING')
 except ImportError:
     _MISSING = object()
@@ -1537,7 +1538,7 @@ class OrderedMultiDict(dict):
 
 try:
     # try to import the built-in one anyways
-    from boltons.dictutils import OrderedMultiDict
+    from .dictutils import OrderedMultiDict
 except ImportError:
     pass
 
diff --git a/debian/changelog b/debian/changelog
index 5bf440b..a10a39e 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,11 @@
+python-boltons (23.0.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+  * Drop patch address-ecoutils-import-issue-fixes-294.patch, present upstream.
+  * Drop patch python3.11.patch, present upstream.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Fri, 10 Mar 2023 22:36:41 -0000
+
 python-boltons (21.0.0-2) unstable; urgency=medium
 
   * Team upload.
diff --git a/debian/patches/address-ecoutils-import-issue-fixes-294.patch b/debian/patches/address-ecoutils-import-issue-fixes-294.patch
deleted file mode 100644
index d6bdd9f..0000000
--- a/debian/patches/address-ecoutils-import-issue-fixes-294.patch
+++ /dev/null
@@ -1,118 +0,0 @@
-From: Mahmoud Hashemi <mahmoud@hatnote.com>
-Date: Sun, 10 Oct 2021 23:26:24 -0700
-Subject: address ecoutils import issue, fixes #294
-
-Origin: upstream, https://github.com/mahmoud/boltons/commit/270e974975984f662f998c8f6eb0ebebd964de82
-Bug-Debian: https://bugs.debian.org/1002347
----
- boltons/ecoutils.py | 87 +++++++++++++++++++++++++++--------------------------
- 1 file changed, 44 insertions(+), 43 deletions(-)
-
-diff --git a/boltons/ecoutils.py b/boltons/ecoutils.py
-index 0ccad70..91b9412 100644
---- a/boltons/ecoutils.py
-+++ b/boltons/ecoutils.py
-@@ -354,38 +354,53 @@ def get_profile(**kwargs):
-     return ret
- 
- 
--_real_safe_repr = pprint._safe_repr
--
--
--def _fake_json_dumps(val, indent=2):
--    # never do this. this is a hack for Python 2.4. Python 2.5 added
--    # the json module for a reason.
--    def _fake_safe_repr(*a, **kw):
--        res, is_read, is_rec = _real_safe_repr(*a, **kw)
--        if res == 'None':
--            res = 'null'
--        if res == 'True':
--            res = 'true'
--        if res == 'False':
--            res = 'false'
--        if not (res.startswith("'") or res.startswith("u'")):
--            res = res
--        else:
--            if res.startswith('u'):
--                res = res[1:]
-+try:
-+    import json
-+
-+    def dumps(val, indent):
-+        if indent:
-+            return json.dumps(val, sort_keys=True, indent=indent)
-+        return json.dumps(val, sort_keys=True)
-+
-+except ImportError:
-+    _real_safe_repr = pprint._safe_repr
-+
-+    def _fake_json_dumps(val, indent=2):
-+        # never do this. this is a hack for Python 2.4. Python 2.5 added
-+        # the json module for a reason.
-+        def _fake_safe_repr(*a, **kw):
-+            res, is_read, is_rec = _real_safe_repr(*a, **kw)
-+            if res == 'None':
-+                res = 'null'
-+            if res == 'True':
-+                res = 'true'
-+            if res == 'False':
-+                res = 'false'
-+            if not (res.startswith("'") or res.startswith("u'")):
-+                res = res
-+            else:
-+                if res.startswith('u'):
-+                    res = res[1:]
- 
--            contents = res[1:-1]
--            contents = contents.replace('"', '').replace(r'\"', '')
--            res = '"' + contents + '"'
--        return res, is_read, is_rec
-+                contents = res[1:-1]
-+                contents = contents.replace('"', '').replace(r'\"', '')
-+                res = '"' + contents + '"'
-+            return res, is_read, is_rec
- 
--    pprint._safe_repr = _fake_safe_repr
--    try:
--        ret = pprint.pformat(val, indent=indent)
--    finally:
--        pprint._safe_repr = _real_safe_repr
-+        pprint._safe_repr = _fake_safe_repr
-+        try:
-+            ret = pprint.pformat(val, indent=indent)
-+        finally:
-+            pprint._safe_repr = _real_safe_repr
-+
-+        return ret
-+
-+    def dumps(val, indent):
-+        ret = _fake_json_dumps(val, indent=indent)
-+        if not indent:
-+            ret = re.sub(r'\n\s*', ' ', ret)
-+        return ret
- 
--    return ret
- 
- 
- def get_profile_json(indent=False):
-@@ -393,20 +408,6 @@ def get_profile_json(indent=False):
-         indent = 2
-     else:
-         indent = 0
--    try:
--        import json
--
--        def dumps(val, indent):
--            if indent:
--                return json.dumps(val, sort_keys=True, indent=indent)
--            return json.dumps(val, sort_keys=True)
--
--    except ImportError:
--        def dumps(val, indent):
--            ret = _fake_json_dumps(val, indent=indent)
--            if not indent:
--                ret = re.sub(r'\n\s*', ' ', ret)
--            return ret
- 
-     data_dict = get_profile()
-     return dumps(data_dict, indent)
diff --git a/debian/patches/python3.11.patch b/debian/patches/python3.11.patch
deleted file mode 100644
index 6046743..0000000
--- a/debian/patches/python3.11.patch
+++ /dev/null
@@ -1,24 +0,0 @@
-From: Stefano Rivera <stefano@rivera.za.net>
-Date: Sun, 25 Dec 2022 10:40:05 -0400
-Subject: Add __getstate__ to through_methods (bpo-26579)
-
-Adds support for Python 3.11.
-
-Bug-Debian: https://bugs.debian.org/1025023
-Forwarded: https://github.com/mahmoud/boltons/pull/323
----
- tests/test_dictutils.py | 1 +
- 1 file changed, 1 insertion(+)
-
-diff --git a/tests/test_dictutils.py b/tests/test_dictutils.py
-index ca59a31..0044001 100644
---- a/tests/test_dictutils.py
-+++ b/tests/test_dictutils.py
-@@ -474,6 +474,7 @@ def test_frozendict_api():
-                        '__ge__',
-                        '__getattribute__',
-                        '__getitem__',
-+                       '__getstate__',
-                        '__gt__',
-                        '__init__',
-                        '__iter__',
diff --git a/debian/patches/series b/debian/patches/series
index 7a900d5..e69de29 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1,2 +0,0 @@
-address-ecoutils-import-issue-fixes-294.patch
-python3.11.patch
diff --git a/docs/conf.py b/docs/conf.py
index a93823b..e66f88a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -97,11 +97,11 @@ master_doc = 'index'
 
 # General information about the project.
 project = u'boltons'
-copyright = u'2020, Mahmoud Hashemi'
+copyright = u'2023, Mahmoud Hashemi'
 author = u'Mahmoud Hashemi'
 
-version = '21.0'
-release = '21.0.0'
+version = '23.0'
+release = '23.0.0'
 
 if os.name != 'nt':
     today_fmt = '%B %d, %Y'
diff --git a/docs/index.rst b/docs/index.rst
index ca1c9a5..4ebcdcf 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -68,7 +68,7 @@ consider other integration options. See the :ref:`Integration
 <arch_integration>` section of the architecture document for more
 details.
 
-Boltons is tested against Python 2.6-2.7, 3.4-3.7, and PyPy.
+Boltons is tested against Python 3.7-3.11, as well as Python 2.7 and PyPy3.
 
 .. _MacPorts: https://ports.macports.org/port/py-boltons/summary
 
diff --git a/docs/iterutils.rst b/docs/iterutils.rst
index 1cb5c75..23165a0 100644
--- a/docs/iterutils.rst
+++ b/docs/iterutils.rst
@@ -18,6 +18,7 @@ present in the standard library.
 
 .. autofunction:: chunked
 .. autofunction:: chunked_iter
+.. autofunction:: chunk_ranges
 .. autofunction:: pairwise
 .. autofunction:: pairwise_iter
 .. autofunction:: windowed
diff --git a/requirements-test.txt b/requirements-test.txt
index f030ce8..ae05d73 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,4 +1,4 @@
-coverage==4.5.1
-pytest==3.0.7
-pytest-cov==2.5.1
+coverage==6.5.0
+pytest==7.2.0
+pytest-cov==4.0.0
 tox<3.0
diff --git a/setup.py b/setup.py
index f4b8723..86971b2 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ from setuptools import setup
 
 
 __author__ = 'Mahmoud Hashemi'
-__version__ = '21.0.0'
+__version__ = '23.0.0'
 __contact__ = 'mahmoud@hatnote.com'
 __url__ = 'https://github.com/mahmoud/boltons'
 __license__ = 'BSD'
@@ -42,15 +42,13 @@ setup(name='boltons',
           # List of python versions and their support status:
           # https://en.wikipedia.org/wiki/CPython#Version_history
           'Programming Language :: Python :: 2',
-          'Programming Language :: Python :: 2.6',
           'Programming Language :: Python :: 2.7',
           'Programming Language :: Python :: 3',
-          'Programming Language :: Python :: 3.4',
-          'Programming Language :: Python :: 3.5',
-          '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 :: Implementation :: CPython',
           'Programming Language :: Python :: Implementation :: PyPy', ]
       )
diff --git a/tests/test_cacheutils.py b/tests/test_cacheutils.py
index cac47f9..91ebcf9 100644
--- a/tests/test_cacheutils.py
+++ b/tests/test_cacheutils.py
@@ -38,7 +38,7 @@ def test_lri():
             for char in string.ascii_letters[least_recent_insert_index+1:idx]:
                 assert char in bc
 
-    # test that reinserting an exising key changes eviction behavior
+    # test that reinserting an existing key changes eviction behavior
     bc[string.ascii_letters[-cache_size+1]] = "new value"
     least_recently_inserted_key = string.ascii_letters[-cache_size+2]
     bc["unreferenced_key"] = "value"
diff --git a/tests/test_dictutils.py b/tests/test_dictutils.py
index ca59a31..ae046f6 100644
--- a/tests/test_dictutils.py
+++ b/tests/test_dictutils.py
@@ -473,7 +473,9 @@ def test_frozendict_api():
                        '__format__',
                        '__ge__',
                        '__getattribute__',
+                       '__getstate__',
                        '__getitem__',
+                       '__getstate__',
                        '__gt__',
                        '__init__',
                        '__iter__',
diff --git a/tests/test_formatutils.py b/tests/test_formatutils.py
index fee81a7..d7bfaa9 100644
--- a/tests/test_formatutils.py
+++ b/tests/test_formatutils.py
@@ -29,15 +29,8 @@ _TEST_TMPLS = ["example 1: {hello}",
                "example 2: {hello:*10}",
                "example 3: {hello:*{width}}",
                "example 4: {hello!r:{fchar}{width}}, {width}, yes",
-               "example 5: {0}, {1:d}, {2:f}, {1}"]
-
-try:
-    from collections import OrderedDict
-except ImportError:
-    pass  # skip the non-2.6 compatible tests on 2.6
-else:
-    _TEST_TMPLS.append("example 6: {}, {}, {}, {1}")
-    del OrderedDict
+               "example 5: {0}, {1:d}, {2:f}, {1}",
+               "example 6: {}, {}, {}, {1}"]
 
 
 def test_get_fstr_args():
@@ -45,8 +38,7 @@ def test_get_fstr_args():
     for t in _TEST_TMPLS:
         inferred_t = infer_positional_format_args(t)
         res = get_format_args(inferred_t)
-        results.append(res)
-    return results
+        assert res
 
 
 def test_split_fstr():
@@ -54,7 +46,7 @@ def test_split_fstr():
     for t in _TEST_TMPLS:
         res = split_format_str(t)
         results.append(res)
-    return results
+        assert res
 
 
 def test_tokenize_format_str():
@@ -62,7 +54,7 @@ def test_tokenize_format_str():
     for t in _TEST_TMPLS:
         res = tokenize_format_str(t)
         results.append(res)
-    return results
+        assert res
 
 
 def test_deferredvalue():
diff --git a/tests/test_ioutils.py b/tests/test_ioutils.py
index b14dd66..df672ce 100644
--- a/tests/test_ioutils.py
+++ b/tests/test_ioutils.py
@@ -75,6 +75,29 @@ class BaseTestMixin(object):
         finally:
             os.rmdir(custom_dir)
 
+    def test_compare_err(self):
+        """Read-heads are reset if a comparison raises an error."""
+        def _monkey_err(*args, **kwargs):
+            raise Exception('A sad error has occurred today')
+
+        a = self.spooled_flo.__class__()
+        a.write(self.test_str)
+        b = self.spooled_flo.__class__()
+        b.write(self.test_str)
+
+        a.seek(1)
+        b.seek(2)
+
+        b.__next__ = _monkey_err
+
+        try:
+            a == b
+        except Exception:
+            pass
+
+        self.assertEqual(a.tell(), 1)
+        self.assertEqual(b.tell(), 2)
+
     def test_truncate_noargs_norollover(self):
         """Test truncating with no args with in-memory flo"""
         self.spooled_flo.write(self.test_str)
@@ -190,6 +213,23 @@ class BaseTestMixin(object):
         if not self.spooled_flo:
             raise AssertionError("Instance is not truthy")
 
+    def test_instance_check(self):
+        """Instance checks against IOBase succeed."""
+        if not isinstance(self.spooled_flo, io.IOBase):
+            raise AssertionError('{} is not an instance of IOBase'.format(type(self.spooled_flo)))
+
+    def test_closed_file_method_valueerrors(self):
+        """ValueError raised on closed files for certain methods."""
+        self.spooled_flo.close()
+        methods = (
+            'flush', 'isatty', 'pos', 'buf', 'truncate', '__next__', '__iter__',
+            '__enter__', 'read', 'readline', 'tell',
+        )
+        for method_name in methods:
+            with self.assertRaises(ValueError):
+                getattr(self.spooled_flo, method_name)()
+
+
 
 class TestSpooledBytesIO(TestCase, BaseTestMixin, AssertionsMixin):
     linesep = os.linesep.encode('ascii')
@@ -270,6 +310,13 @@ class TestSpooledBytesIO(TestCase, BaseTestMixin, AssertionsMixin):
         self.spooled_flo.seek(0)
         self.assertEqual([x for x in self.spooled_flo], [b"a\n", b"b"])
 
+    def test_writelines(self):
+        """An iterable of lines can be written"""
+        lines = [b"1", b"2", b"3"]
+        expected = b"123"
+        self.spooled_flo.writelines(lines)
+        self.assertEqual(self.spooled_flo.getvalue(), expected)
+
 
 class TestSpooledStringIO(TestCase, BaseTestMixin, AssertionsMixin):
     linesep = os.linesep
@@ -408,6 +455,13 @@ class TestSpooledStringIO(TestCase, BaseTestMixin, AssertionsMixin):
         self.spooled_flo.seek(0)
         self.assertEqual([x for x in self.spooled_flo], [u"a\n", u"b"])
 
+    def test_writelines(self):
+        """An iterable of lines can be written"""
+        lines = [u"1", u"2", u"3"]
+        expected = u"123"
+        self.spooled_flo.writelines(lines)
+        self.assertEqual(self.spooled_flo.getvalue(), expected)
+
 
 class TestMultiFileReader(TestCase):
     def test_read_seek_bytes(self):
diff --git a/tests/test_iterutils.py b/tests/test_iterutils.py
index 0896f38..2738d4b 100644
--- a/tests/test_iterutils.py
+++ b/tests/test_iterutils.py
@@ -511,6 +511,22 @@ def test_chunked_bytes():
     assert chunked(b'123', 2) in (['12', '3'], [b'12', b'3'])
 
 
+def test_chunk_ranges():
+    from boltons.iterutils import chunk_ranges
+
+    assert list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5)) == [(10, 15), (15, 20)]
+    assert list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5, overlap_size=1)) == [(10, 15), (14, 19), (18, 20)]
+    assert list(chunk_ranges(input_offset=10, input_size=10, chunk_size=5, overlap_size=2)) == [(10, 15), (13, 18), (16, 20)]
+
+    assert list(chunk_ranges(input_offset=4, input_size=15, chunk_size=5, align=False)) == [(4, 9), (9, 14), (14, 19)]
+    assert list(chunk_ranges(input_offset=4, input_size=15, chunk_size=5, align=True)) == [(4, 5), (5, 10), (10, 15), (15, 19)]
+
+    assert list(chunk_ranges(input_offset=2, input_size=15, chunk_size=5, overlap_size=1, align=False)) == [(2, 7), (6, 11), (10, 15), (14, 17)]
+    assert list(chunk_ranges(input_offset=2, input_size=15, chunk_size=5, overlap_size=1, align=True)) == [(2, 5), (4, 9), (8, 13), (12, 17)]
+    assert list(chunk_ranges(input_offset=3, input_size=15, chunk_size=5, overlap_size=1, align=True)) == [(3, 5), (4, 9), (8, 13), (12, 17), (16, 18)]
+    assert list(chunk_ranges(input_offset=3, input_size=2, chunk_size=5, overlap_size=1, align=True)) == [(3, 5)]
+
+
 def test_lstrip():
     from boltons.iterutils import lstrip
 
diff --git a/tests/test_mathutils.py b/tests/test_mathutils.py
index 40ae40a..42915e9 100644
--- a/tests/test_mathutils.py
+++ b/tests/test_mathutils.py
@@ -95,15 +95,19 @@ def test_bits():
     chk(Bits('11') >> 1, Bits('1'))
     chk(Bits('1') << 1, Bits('10'))
     assert Bits('0') != Bits('00')
+    # test roundtrip as_/from_hex
+    chk(Bits.from_hex(Bits('10101010').as_hex()),
+        Bits('10101010'))
+    # test roundtrip as_/from_bytes
     chk(
-        Bits.from_bytes(
-            Bits.from_int(
-                Bits.from_hex(
-                    Bits.from_bin(
-                        Bits.from_list(
-                            Bits('101').as_list()
-                        ).as_bin()
-                    ).as_hex()
-                ).as_int()
-            ).as_bytes()
-    ), Bits('00000101'))
+        Bits.from_bytes(Bits('10101010').as_bytes()),
+        Bits('10101010'))
+    # pile of roundtripping
+    chk(Bits.from_int(
+            Bits.from_bin(
+                Bits.from_list(
+                    Bits('101').as_list()
+                ).as_bin()
+            ).as_int()
+        ),
+        Bits('101'))
diff --git a/tests/test_socketutils.py b/tests/test_socketutils.py
index dd095eb..54cee6a 100644
--- a/tests/test_socketutils.py
+++ b/tests/test_socketutils.py
@@ -377,7 +377,7 @@ def test_socketutils_netstring():
 
 def netstring_server_timeout_override(server_socket):
     """Netstring socket has an unreasonably low timeout,
-    however it should be overriden by the `read_ns` argument."""
+    however it should be overridden by the `read_ns` argument."""
 
     try:
         while True:
@@ -398,9 +398,9 @@ def netstring_server_timeout_override(server_socket):
 
 
 def test_socketutils_netstring_timeout():
-    """Tests that server socket timeout is overriden by the argument to read call.
+    """Tests that server socket timeout is overridden by the argument to read call.
 
-    Server has timeout of 10 ms, and we will sleep for 20 ms. If timeout is not overriden correctly,
+    Server has timeout of 10 ms, and we will sleep for 20 ms. If timeout is not overridden correctly,
     a timeout exception will be raised."""
 
     print("running timeout test")
@@ -426,4 +426,4 @@ def test_socketutils_netstring_timeout():
     assert client.read_ns() == b'pong'
 
     client.write_ns(b'shutdown')
-    print("no timeout occured - all good.")
\ No newline at end of file
+    print("no timeout occurred - all good.")
\ No newline at end of file
diff --git a/tests/test_timeutils.py b/tests/test_timeutils.py
index 3e91ce6..9f0e19a 100644
--- a/tests/test_timeutils.py
+++ b/tests/test_timeutils.py
@@ -1,5 +1,8 @@
 
 from datetime import timedelta, date
+
+import pytest
+
 from boltons.timeutils import total_seconds, daterange
 
 
@@ -48,8 +51,35 @@ def test_daterange_years():
     assert years_from_2025[0] == date(2025, 1, 1)
     assert years_from_2025[-1] == date(2017, 1, 1)
 
+
+def test_daterange_years_step():
+    start_day = date(year=2012, month=12, day=25)
+    end_day = date(year=2016, month=1, day=1)
+    dates = list(daterange(start_day, end_day, step=(1, 0, 0), inclusive=False))
+    expected = [date(year=2012, month=12, day=25), date(year=2013, month=12, day=25), date(year=2014, month=12, day=25), date(year=2015, month=12, day=25)]
+
+    assert dates == expected
+
+    dates = list(daterange(start_day, end_day, step=(0, 13, 0), inclusive=False))
+    expected = [date(year=2012, month=12, day=25), date(year=2014, month=1, day=25), date(year=2015, month=2, day=25)]
+    assert dates == expected
+    
+    
 def test_daterange_infinite():
     today = date.today()
     infinite_dates = daterange(today, None)
     for i in range(10):
         assert next(infinite_dates) == today + timedelta(days=i)
+
+
+def test_daterange_with_same_start_stop():
+    today = date.today()
+
+    date_range = daterange(today, today)
+    with pytest.raises(StopIteration):
+        next(date_range)
+
+    date_range_inclusive = daterange(today, today, inclusive=True)
+    assert next(date_range_inclusive) == today
+    with pytest.raises(StopIteration):
+        next(date_range_inclusive)
diff --git a/tests/test_urlutils.py b/tests/test_urlutils.py
index a8e1539..d551ae0 100644
--- a/tests/test_urlutils.py
+++ b/tests/test_urlutils.py
@@ -99,7 +99,7 @@ def test_idna():
 def test_query_params(test_url):
     url_obj = URL(test_url)
     if not url_obj.query_params or url_obj.fragment:
-        return True
+        return 
     qp_text = url_obj.query_params.to_text(full_quote=True)
     assert test_url.endswith(qp_text)
 
@@ -313,6 +313,25 @@ def test_navigate():
     assert navd.to_text() == _dest_text
 
 
+@pytest.mark.parametrize(
+    ('expected', 'base', 'paths'), [
+    ('https://host/b', 'https://host', ('a', '/b', )),
+    ('https://host/b', 'https://host', ('a', 'b', )),
+    ('https://host/a/b', 'https://host', ('a/', 'b', )),
+    ('https://host/b', 'https://host', ('/a', 'b', )),
+    ('https://host/a/b', 'https://host/a/', (None, 'b', )),
+    ('https://host/b', 'https://host/a', (None, 'b', )),
+])
+def test_chained_navigate(expected, base, paths):
+    """Chained :meth:`navigate` calls produces correct results."""
+    url = URL(base)
+
+    for path in paths:
+        url = url.navigate(path)
+
+    assert expected == url.to_text()
+
+
 # TODO: RFC3986 6.2.3 (not just for query add, either)
 # def test_add_query():
 #     url = URL('http://www.example.com')
diff --git a/tox.ini b/tox.ini
index 1392927..64e32f1 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,13 @@
 [tox]
-envlist = py27,py34,py37,py39,pypy
+envlist = py27,py37,py39,py310,py311,pypy3
 [testenv]
 changedir = .tox
 deps = -rrequirements-test.txt
 commands = py.test --doctest-modules {envsitepackagesdir}/boltons {toxinidir}/tests {posargs}
+
+[testenv:py27]
+deps =
+    coverage==5.5
+    pytest==4.6.11
+    pytest-cov==2.12
+    

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-23.0.0.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-23.0.0.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-23.0.0.egg-info/not-zip-safe
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-23.0.0.egg-info/top_level.txt

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-21.0.0.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-21.0.0.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-21.0.0.egg-info/not-zip-safe
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/boltons-21.0.0.egg-info/top_level.txt

No differences were encountered in the control files

More details

Full run details