Repository: mahmoud/boltons

Issue:
Represent the URL when str(url)
When working with `boltons.urlutils.URL`, it would be nice for `str(url)` to generate a string version of the URL, the same way it does when executing `url.to_text()`.

As of now it returns `URL('...')`, which is what you would expect when executing `repr()` but not `str()` (as per python docs, str should return a nice human representation whereas repr a formal python expression).

A part from making URL more intuitive and pythonic to use, this small change would allow it to nicely integrate with other libraries like [requests](https://github.com/requests/requests), which [automatically performs `str()`](https://github.com/requests/requests/pull/766) to non-string passed-in objects, so this would work:
```python
url = boltons.urlutils.URL('http://foo.com')
response = requests.get(url)
```
Which is just extra nice and useful :-)

Anyway, it is just a small enhancement in an already amazing library. Congratulations for it!

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 685ee76..517970b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,5 +1,4 @@
 language: python
-sudo: false
 cache: pip
 
 # Python targets, as defined by https://github.com/travis-ci/travis-build/blob
@@ -28,10 +27,8 @@ matrix:
   include:
     - python: 3.7
       dist: xenial     # required for Python 3.7 (travis-ci/travis-ci#9069)
-      sudo: required   # required for Python 3.7 (travis-ci/travis-ci#9069)
     - python: nightly  # Python 3.8.0a0
       dist: xenial     # required for Python 3.8-dev (travis-ci/travis-ci#9069)
-      sudo: required   # required for Python 3.8-dev (travis-ci/travis-ci#9069)
   allow_failures:
     - python: nightly
 
diff --git a/boltons/cacheutils.py b/boltons/cacheutils.py
index 6f90651..01009f0 100644
--- a/boltons/cacheutils.py
+++ b/boltons/cacheutils.py
@@ -511,6 +511,7 @@ class CachedMethod(object):
     """
     def __init__(self, func, cache, scoped=True, typed=False, key=None):
         self.func = func
+        self.__isabstractmethod__ = getattr(func, '__isabstractmethod__', False)
         if isinstance(cache, basestring):
             self.get_cache = attrgetter(cache)
         elif callable(cache):
@@ -652,6 +653,7 @@ class cachedproperty(object):
     """
     def __init__(self, func):
         self.__doc__ = getattr(func, '__doc__')
+        self.__isabstractmethod__ = getattr(func, '__isabstractmethod__', False)
         self.func = func
 
     def __get__(self, obj, objtype=None):
diff --git a/boltons/dictutils.py b/boltons/dictutils.py
index f3583f7..c38fc26 100644
--- a/boltons/dictutils.py
+++ b/boltons/dictutils.py
@@ -106,17 +106,11 @@ class OrderedMultiDict(dict):
     >>> omd
     OrderedMultiDict([('b', 2)])
 
-    Note that calling :func:`dict` on an OMD results in a dict of keys
-    to *lists* of values:
+    If you want a safe-to-modify or flat dictionary, use
+    :meth:`OrderedMultiDict.todict()`.
 
-    >>> from pprint import pprint as pp  # ensuring proper key ordering
+    >>> from pprint import pprint as pp  # preserve printed ordering
     >>> omd = OrderedMultiDict([('a', 1), ('b', 2), ('a', 3)])
-    >>> pp(dict(omd))
-    {'a': [1, 3], 'b': [2]}
-
-    Note that modifying those lists will modify the OMD. If you want a
-    safe-to-modify or flat dictionary, use :meth:`OrderedMultiDict.todict()`.
-
     >>> pp(omd.todict())
     {'a': 3, 'b': 2}
     >>> pp(omd.todict(multi=True))
@@ -129,6 +123,19 @@ class OrderedMultiDict(dict):
     >>> OrderedMultiDict([('a', 1), ('b', 2), ('a', 3)]).items(multi=False)
     [('a', 3), ('b', 2)]
 
+    .. warning::
+
+       ``dict(omd)`` changed behavior `in Python 3.7
+       <https://bugs.python.org/issue34320>`_ due to changes made to
+       support the transition from :class:`collections.OrderedDict` to
+       the built-in dictionary being ordered. Before 3.7, the result
+       would be a new dictionary, with values that were lists, similar
+       to ``omd.todict(multi=True)`` (but only shallow-copy; the lists
+       were direct references to OMD internal structures). From 3.7
+       onward, the values became singular, like
+       ``omd.todict(multi=False)``. For reliable cross-version
+       behavior, just use :meth:`~OrderedMultiDict.todict()`.
+
     """
     def __init__(self, *args, **kwargs):
         if len(args) > 1:
diff --git a/boltons/fileutils.py b/boltons/fileutils.py
index 459b762..4d05647 100644
--- a/boltons/fileutils.py
+++ b/boltons/fileutils.py
@@ -455,9 +455,9 @@ _CUR_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
 def iter_find_files(directory, patterns, ignored=None):
-    """Returns a generator that yields file paths under a *directory*,
-    matching *patterns* using `glob`_ syntax (e.g., ``*.txt``). Also
-    supports *ignored* patterns.
+    """Returns a generator that yields file paths under a *directory*
+    (recursively), matching *patterns* using `glob`_ syntax (e.g., ``*.txt``).
+    Also supports *ignored* patterns.
 
     Args:
         directory (str): Path that serves as the root of the
diff --git a/boltons/funcutils.py b/boltons/funcutils.py
index d5b7f33..3e30017 100644
--- a/boltons/funcutils.py
+++ b/boltons/funcutils.py
@@ -514,8 +514,6 @@ class FunctionBuilder(object):
         body = self.body or self._default_body
 
         tmpl = 'def {name}{sig_str}:'
-        if self.doc:
-            tmpl += '\n    """{doc}"""'
         tmpl += '\n{body}'
 
         body = _indent(self.body, ' ' * self.indent)
diff --git a/boltons/iterutils.py b/boltons/iterutils.py
index 8f93cb2..bf89f1f 100644
--- a/boltons/iterutils.py
+++ b/boltons/iterutils.py
@@ -1182,6 +1182,51 @@ guid_iter = GUIDerator()
 seq_guid_iter = SequentialGUIDerator()
 
 
+def soft_sorted(iterable, first=None, last=None, key=None, reverse=False):
+    """For when you care about the order of some elements, but not about
+    others.
+
+    Use this to float to the top and/or sink to the bottom a specific
+    ordering, while sorting the rest of the elements according to
+    normal :func:`sorted` rules.
+
+    >>> soft_sorted(['two', 'b', 'one', 'a'], first=['one', 'two'])
+    ['one', 'two', 'a', 'b']
+    >>> soft_sorted(range(7), first=[6, 15], last=[2, 4], reverse=True)
+    [6, 5, 3, 1, 0, 2, 4]
+    >>> import string
+    >>> ''.join(soft_sorted(string.hexdigits, first='za1', last='b', key=str.lower))
+    'aA1023456789cCdDeEfFbB'
+
+    Args:
+       iterable (list): A list or other iterable to sort.
+       first (list): A sequence to enforce for elements which should
+          appear at the beginning of the returned list.
+       last (list): A sequence to enforce for elements which should
+          appear at the end of the returned list.
+       key (callable): Callable used to generate a comparable key for
+          each item to be sorted, same as the key in
+          :func:`sorted`. Note that entries in *first* and *last*
+          should be the keys for the items. Defaults to
+          passthrough/the identity function.
+       reverse (bool): Whether or not elements not explicitly ordered
+          by *first* and *last* should be in reverse order or not.
+
+    Returns a new list in sorted order.
+    """
+    first = first or []
+    last = last or []
+    key = key or (lambda x: x)
+    seq = list(iterable)
+    other = [x for x in seq if not ((first and key(x) in first) or (last and key(x) in last))]
+    other.sort(key=key, reverse=reverse)
+
+    if first:
+        first = sorted([x for x in seq if key(x) in first], key=lambda x: first.index(key(x)))
+    if last:
+        last = sorted([x for x in seq if key(x) in last], key=lambda x: last.index(key(x)))
+    return first + other + last
+
 """
 May actually be faster to do an isinstance check for a str path
 
diff --git a/boltons/setutils.py b/boltons/setutils.py
index 3331069..6d0a957 100644
--- a/boltons/setutils.py
+++ b/boltons/setutils.py
@@ -402,9 +402,9 @@ class IndexedSet(MutableSet):
             self.item_index_map[item] = i
         del self.dead_indices[:]
 
-    def sort(self):
+    def sort(self, **kwargs):
         "sort() -> sort the contents of the set in-place"
-        sorted_list = sorted(self)
+        sorted_list = sorted(self, **kwargs)
         if sorted_list == self.item_list:
             return
         self.item_list[:] = sorted_list
diff --git a/boltons/strutils.py b/boltons/strutils.py
index 52f613d..a5baada 100644
--- a/boltons/strutils.py
+++ b/boltons/strutils.py
@@ -175,8 +175,12 @@ def singularize(word):
     """Semi-intelligently converts an English plural *word* to its
     singular form, preserving case pattern.
 
-    >>> singularize('records')
-    'record'
+    >>> singularize('chances')
+    'chance'
+    >>> singularize('Activities')
+    'Activity'
+    >>> singularize('Glasses')
+    'Glass'
     >>> singularize('FEET')
     'FOOT'
 
@@ -192,9 +196,9 @@ def singularize(word):
         return orig_word
     elif len(word) == 2:
         singular = word[:-1]  # or just return word?
-    elif word.endswith('ies') and word[-5:-4] not in 'aeiou':
+    elif word.endswith('ies') and word[-4:-3] not in 'aeiou':
         singular = word[:-3] + 'y'
-    elif word.endswith('es'):
+    elif word.endswith('es') and word[-3] == 's':
         singular = word[:-2]
     else:
         singular = word[:-1]
@@ -234,8 +238,8 @@ def _match_case(master, disciple):
         return disciple.lower()
     elif master.upper() == master:
         return disciple.upper()
-    elif master.capitalize() == master:
-        return disciple.capitalize()
+    elif master.title() == master:
+        return disciple.title()
     return disciple
 
 
diff --git a/boltons/tableutils.py b/boltons/tableutils.py
index bc769d7..5cd07e0 100644
--- a/boltons/tableutils.py
+++ b/boltons/tableutils.py
@@ -27,10 +27,12 @@ from itertools import islice
 from collections import Sequence, Mapping, MutableSequence
 try:
     string_types, integer_types = (str, unicode), (int, long)
+    from cgi import escape as html_escape
 except NameError:
     # Python 3 compat
     unicode = str
     string_types, integer_types = (str, bytes), (int,)
+    from html import escape as html_escape
 
 try:
     from typeutils import make_sentinel
@@ -75,7 +77,7 @@ def to_text(obj, maxlen=None):
 
 def escape_html(obj, maxlen=None):
     text = to_text(obj, maxlen=maxlen)
-    return cgi.escape(text, quote=True)
+    return html_escape(text, quote=True)
 
 
 _DNR = set((type(None), bool, complex, float,
diff --git a/boltons/urlutils.py b/boltons/urlutils.py
index c96ec08..223fad7 100644
--- a/boltons/urlutils.py
+++ b/boltons/urlutils.py
@@ -770,6 +770,12 @@ class URL(object):
         cn = self.__class__.__name__
         return u'%s(%r)' % (cn, self.to_text())
 
+    def __str__(self):
+        return self.to_text()
+
+    def __unicode__(self):
+        return self.to_text()
+
     def __eq__(self, other):
         for attr in self._cmp_attrs:
             if not getattr(self, attr) == getattr(other, attr, None):


Test patch:
diff --git a/tests/test_cacheutils.py b/tests/test_cacheutils.py
index e617492..3911606 100644
--- a/tests/test_cacheutils.py
+++ b/tests/test_cacheutils.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
-
 import string
+from abc import abstractmethod, ABCMeta
+
+import pytest
 
 from boltons.cacheutils import LRU, LRI, cached, cachedmethod, cachedproperty, MinIDMap
 
@@ -263,6 +265,24 @@ def test_cachedmethod():
     return
 
 
+def test_cachedmethod_maintains_func_abstraction():
+    ABC = ABCMeta('ABC', (object,), {})
+
+    class Car(ABC):
+
+        def __init__(self, cache=None):
+            self.h_cache = LRI() if cache is None else cache
+            self.hand_count = 0
+
+        @cachedmethod('h_cache')
+        @abstractmethod
+        def hand(self, *a, **kw):
+            self.hand_count += 1
+
+    with pytest.raises(TypeError):
+        Car()
+
+
 def test_cachedproperty():
     class Proper(object):
         def __init__(self):
@@ -295,6 +315,20 @@ def test_cachedproperty():
     repr(Proper.useful_attr)
 
 
+def test_cachedproperty_maintains_func_abstraction():
+    ABC = ABCMeta('ABC', (object,), {})
+
+    class AbstractExpensiveCalculator(ABC):
+
+        @cachedproperty
+        @abstractmethod
+        def calculate(self):
+            pass
+
+    with pytest.raises(TypeError):
+        AbstractExpensiveCalculator()
+
+
 def test_min_id_map():
     import sys
     if '__pypy__' in sys.builtin_module_names:
diff --git a/tests/test_dictutils.py b/tests/test_dictutils.py
index 87c6317..01c9ab5 100644
--- a/tests/test_dictutils.py
+++ b/tests/test_dictutils.py
@@ -27,7 +27,7 @@ def test_todict():
     assert len(omd) == 1
     assert omd['A'] == 'One'
 
-    d = dict(omd)
+    d = omd.todict(multi=True)
     assert len(d) == 1
     assert d['A'] == ['One', 'One', 'One']
 
@@ -40,6 +40,7 @@ def test_todict():
 
         flat = omd.todict()
         assert flat == d
+    return
 
 
 def test_eq():
diff --git a/tests/test_funcutils.py b/tests/test_funcutils.py
index fe00c83..a0047a3 100644
--- a/tests/test_funcutils.py
+++ b/tests/test_funcutils.py
@@ -42,7 +42,7 @@ def test_copy_function():
     assert callee() == callee_copy()
 
 
-class test_total_ordering():
+def test_total_ordering():
     @total_ordering
     class Number(object):
         def __init__(self, val):
diff --git a/tests/test_funcutils_fb.py b/tests/test_funcutils_fb.py
index 625a85c..5420bc7 100644
--- a/tests/test_funcutils_fb.py
+++ b/tests/test_funcutils_fb.py
@@ -26,15 +26,15 @@ def test_wraps_basic():
 
     @pita_wrap(flag=True)
     def simple_func():
-        "my doc string"
+        '''"""a tricky docstring"""'''
         return 'hello'
 
     assert simple_func() == (True, 'simple_func', 'hello')
-    assert simple_func.__doc__ == "my doc string"
+    assert simple_func.__doc__ == '''"""a tricky docstring"""'''
 
     assert callable(simple_func.__wrapped__)
     assert simple_func.__wrapped__() == 'hello'
-    assert simple_func.__wrapped__.__doc__ == "my doc string"
+    assert simple_func.__wrapped__.__doc__ == '''"""a tricky docstring"""'''
 
     @pita_wrap(flag=False)
     def less_simple_func(arg='hello'):
diff --git a/tests/test_urlutils.py b/tests/test_urlutils.py
index 924bf45..a8e1539 100644
--- a/tests/test_urlutils.py
+++ b/tests/test_urlutils.py
@@ -463,3 +463,6 @@ def test_unicodey():
     assert url.to_text(full_quote=False) == unicodey
     fully_quoted = 'http://xn--9ca.com/%C3%A9?%C3%A1=%C3%AD#%C3%BA'
     assert url.to_text(full_quote=True) == fully_quoted
+
+def test_str_repr():
+    assert str(URL("http://googlewebsite.com/e-shops.aspx")) == "http://googlewebsite.com/e-shops.aspx"