Repository: minrk/escapism

Issue:
Escaping an underscore adds "5F" to the word
Hi!
I use escapism as it is part of the dockerspawner:

https://github.com/jupyterhub/dockerspawner/blob/master/dockerspawner/dockerspawner.py#L20

I have a string containing an underscore, and after escaping it by an underscore, I have an additional two characters: `5F` (which is a part of an URL-encoded underscore). It appears to me that everytime the replacement character (third argument) is actually contained in the original string, it adds characters.

This is very unexpected. And it is very unpractical, as I cannot of course control the characters in my original string (if I could, I would not escape them).

Whether that replacement character is part of the "safe" characters does not affect this behaviour.

I am using Python 3.6 on a linux machine.

Please see here:

```
yyy@yyy:/srv/jupyterhub# python3 
Python 3.6.9 (default, Nov  7 2019, 10:44:02) 
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import escapism
>>> import string
>>> s = 'foo_foo'
>>> safe = set(string.ascii_letters + string.digits + "-")
>>> e = escapism.escape(s, safe, '_')
>>> e
'foo_5Ffoo'
```

It does not change if I add the underscore to the safe chars:

```
>>> import escapism
>>> import string
>>> 
>>> s = 'foo_foo'
>>> safe = set(string.ascii_letters + string.digits + "-"+ "_")
>>> 
>>> 'f' in safe, 'o' in safe, '_' in safe
(True, True, True)
>>> 
>>> escapism.escape(s, safe, '_')
'foo_5Ffoo'

```

And actually, it removes the replacement character from the safe characters:

```
>>> import escapism
>>> 
>>> s = 'foo_foo'
>>> safe = {'f','o','_','x'}
>>> safe
{'o', '_', 'f', 'x'}
>>> escapism.escape(s, safe, 'x')
'foo_foo'
>>> safe
{'o', '_', 'f'}
>>> # x was removed
>>> escapism.escape(s, safe, 'x')
'foo_foo'
>>> safe
{'o', '_', 'f'}

```

And it adds characters to every occurrence of the replacement character in the string, no matter whether it is safe or not:

```
>>> import escapism
>>> s = 'foo_foo'
>>> safe = {'f','o','_'}
>>> safe
{'o', '_', 'f'}
>>> escapism.escape(s, safe, 'o')
'fo6Fo6F_fo6Fo6F' ### <-- chars were added!
>>> safe
{'_', 'f'}      ### <-- o was removed from safe!
```

Can anybody reproduce this?


Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 8cae354..ccda971 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,8 @@ python:
 - 2.7
 - 3.4
 - 3.6
+- 3.7
+- 3.8
 cache:
 - pip
 install:
diff --git a/escapism.py b/escapism.py
index 5517996..e0cd6ce 100644
--- a/escapism.py
+++ b/escapism.py
@@ -13,8 +13,9 @@ no args are provided.
 import re
 import string
 import sys
+import warnings
 
-__version__ = '1.0.0'
+__version__ = "1.0.1"
 
 SAFE = set(string.ascii_letters + string.digits)
 ESCAPE_CHAR = '_'
@@ -67,7 +68,13 @@ def escape(to_escape, safe=SAFE, escape_char=ESCAPE_CHAR, allow_collisions=False
     if allow_collisions:
         safe.add(escape_char)
     elif escape_char in safe:
-        # escape char can't be in safe list
+        warnings.warn(
+            "Escape character %r cannot be a safe character."
+            " Set allow_collisions=True if you want to allow ambiguous escaped strings."
+            % escape_char,
+            RuntimeWarning,
+            stacklevel=2,
+        )
         safe.remove(escape_char)
 
     chars = []
@@ -81,7 +88,7 @@ def escape(to_escape, safe=SAFE, escape_char=ESCAPE_CHAR, allow_collisions=False
 
 def _unescape_char(m):
     """Unescape a single byte
-    
+
     Used as a callback in pattern.subn. `m.group(1)` must be a single byte in hex,
     e.g. `a4` or `ff`.
     """
@@ -90,7 +97,7 @@ def _unescape_char(m):
 
 def unescape(escaped, escape_char=ESCAPE_CHAR):
     """Unescape a string escaped with `escape`
-    
+
     escape_char must be the same as that used in the call to escape.
     """
     if isinstance(escaped, bytes):


Test patch:
diff --git a/tests/test_escapism.py b/tests/test_escapism.py
index 9ba0a52..939ccfc 100644
--- a/tests/test_escapism.py
+++ b/tests/test_escapism.py
@@ -1,4 +1,9 @@
 # coding: utf-8
+
+import warnings
+
+import pytest
+
 from escapism import escape, unescape, SAFE
 
 text = type(u'')
@@ -11,6 +16,7 @@ test_strings = [
     u'_\\-+',
 ]
 
+
 def test_escape_default():
     for s in test_strings:
         e = escape(s)
@@ -19,6 +25,7 @@ def test_escape_default():
         assert isinstance(u, text)
         assert u == s
 
+
 def test_escape_custom_char():
     for escape_char in r'\-%+_':
         for s in test_strings:
@@ -28,6 +35,7 @@ def test_escape_custom_char():
             assert isinstance(u, text)
             assert u == s
 
+
 def test_escape_custom_safe():
     safe = 'ABCDEFabcdef0123456789'
     escape_char = '\\'
@@ -38,14 +46,17 @@ def test_escape_custom_safe():
         u = unescape(e, escape_char=escape_char)
         assert u == s
 
+
 def test_safe_escape_char():
-    escape_char = '-'
+    escape_char = "-"
     safe = SAFE.union({escape_char})
-    e = escape(escape_char, safe=safe, escape_char=escape_char)
-    assert e == '{}{:02X}'.format(escape_char, ord(escape_char))
+    with pytest.warns(RuntimeWarning):
+        e = escape(escape_char, safe=safe, escape_char=escape_char)
+    assert e == "{}{:02X}".format(escape_char, ord(escape_char))
     u = unescape(e, escape_char=escape_char)
     assert u == escape_char
 
+
 def test_allow_collisions():
     escaped = escape('foo-bar ', escape_char='-', allow_collisions=True)
     assert escaped == 'foo-bar-20'