diff --git a/.coveragerc b/.coveragerc
index 6bd5901..e19a474 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -22,6 +22,7 @@ exclude_lines =
 omit =
     src/vistir/_winconsole.py
     src/vistir/termcolors.py
+    src/vistir/backports/*
 
 [html]
 directory = htmlcov
diff --git a/.gitignore b/.gitignore
index 7d3392b..4a1ac81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ __pycache__/
 build/
 develop-eggs/
 dist/
+pip-wheel-metadata/
 downloads/
 eggs/
 .eggs/
@@ -103,3 +104,6 @@ venv.bak/
 # mypy
 .mypy_cache/
 .vscode
+
+# pycharm
+.idea
diff --git a/news/81.feature.rst b/news/81.feature.rst
new file mode 100644
index 0000000..2b63b8d
--- /dev/null
+++ b/news/81.feature.rst
@@ -0,0 +1,1 @@
+Added expanded functionality to assist with the removal of read-only paths on Windows via ``icacls`` system calls if necessary.
diff --git a/news/82.bugfix.rst b/news/82.bugfix.rst
new file mode 100644
index 0000000..2307c01
--- /dev/null
+++ b/news/82.bugfix.rst
@@ -0,0 +1,2 @@
+Fixed a bug with ``vistir.misc.echo`` accidentally wrapping streams with ``colorama`` when it was not needed.
+Fixed a bug with rendering colors in text streams.
diff --git a/setup.cfg b/setup.cfg
index 1d56b26..e2c29da 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,7 +17,7 @@ keywords =
     filesystem
 
 classifier =
-    Development Status :: 3 - Alpha
+    Development Status :: 4 - Beta
     License :: OSI Approved :: ISC License (ISCL)
     Operating System :: OS Independent
     Programming Language :: Python :: 2
@@ -26,11 +26,12 @@ classifier =
     Programming Language :: Python :: 3.4
     Programming Language :: Python :: 3.5
     Programming Language :: Python :: 3.6
+    Programming Language :: Python :: 3.7
     Topic :: Software Development :: Libraries :: Python Modules
 
 [options]
 zip_safe = true
-python_requires = >=2.6,!=3.0,!=3.1,!=3.2,!=3.3
+python_requires = >=2.7,!=3.0,!=3.1,!=3.2,!=3.3
 setup_requires =
     setuptools>=40.8.0
     invoke
@@ -72,14 +73,15 @@ universal = 1
 
 [tool:pytest]
 strict = true
-plugins = cov flake8
-addopts = -ra --cov
+plugins = cov flake8 timeout
+addopts = -ra --cov --timeout 300
 testpaths = tests/
 norecursedirs = .* build dist news tasks docs
 flake8-ignore =
     docs/source/* ALL
     tests/*.py ALL
     setup.py ALL
+    src/vistir/backports/surrogateescape.py ALL
 filterwarnings =
     ignore::DeprecationWarning
     ignore::PendingDeprecationWarning
@@ -106,6 +108,7 @@ exclude =
     .git,
     __pycache__,
     docs/source/*,
+    src/vistir/backports/surrogateescape.py,
     build,
     dist,
     tests/*,
diff --git a/src/vistir/_winconsole.py b/src/vistir/_winconsole.py
index 8f176dd..22eea2c 100644
--- a/src/vistir/_winconsole.py
+++ b/src/vistir/_winconsole.py
@@ -61,8 +61,9 @@ from ctypes import (
     WINFUNCTYPE,
 )
 from ctypes.wintypes import LPWSTR, LPCWSTR
+from itertools import count
 from six import PY2, text_type
-from .misc import StreamWrapper
+from .misc import StreamWrapper, run
 
 try:
     from ctypes import pythonapi
@@ -391,3 +392,111 @@ def show_cursor():
 
 def get_stream_handle(stream):
     return STREAM_MAP.get(stream.fileno())
+
+
+def _walk_for_powershell(directory):
+    for path, dirs, files in os.walk(directory):
+        powershell = next(
+            iter(fn for fn in files if fn.lower() == "powershell.exe"), None
+        )
+        if powershell is not None:
+            return os.path.join(directory, powershell)
+        for subdir in dirs:
+            powershell = _walk_for_powershell(os.path.join(directory, subdir))
+            if powershell:
+                return powershell
+    return None
+
+
+def _get_powershell_path():
+    paths = [
+        os.path.expandvars(r"%windir%\{0}\WindowsPowerShell").format(subdir)
+        for subdir in ("SysWOW64", "system32")
+    ]
+    powershell_path = next(iter(_walk_for_powershell(pth) for pth in paths), None)
+    if not powershell_path:
+        powershell_path, _ = run(
+            ["where", "powershell"], block=True, nospin=True, return_object=False
+        )
+    if powershell_path:
+        return powershell_path.strip()
+    return None
+
+
+def _get_sid_with_powershell():
+    powershell_path = _get_powershell_path()
+    if not powershell_path:
+        return None
+    args = [
+        powershell_path,
+        "-ExecutionPolicy",
+        "Bypass",
+        "-Command",
+        "Invoke-Expression '[System.Security.Principal.WindowsIdentity]::GetCurrent().user | Write-Host'",
+    ]
+    sid, _ = run(args, nospin=True)
+    return sid.strip()
+
+
+def _get_sid_from_registry():
+    try:
+        import winreg
+    except ImportError:
+        import _winreg as winreg
+    var_names = ("%USERPROFILE%", "%HOME%")
+    current_user_home = next(iter(os.path.expandvars(v) for v in var_names if v), None)
+    root, subkey = (
+        winreg.HKEY_LOCAL_MACHINE,
+        r"Software\Microsoft\Windows NT\CurrentVersion\ProfileList",
+    )
+    subkey_names = []
+    value = None
+    matching_key = None
+    try:
+        with winreg.OpenKeyEx(root, subkey, 0, winreg.KEY_READ) as key:
+            for i in count():
+                key_name = winreg.EnumKey(key, i)
+                subkey_names.append(key_name)
+                value = query_registry_value(
+                    root, r"{0}\{1}".format(subkey, key_name), "ProfileImagePath"
+                )
+                if value and value.lower() == current_user_home.lower():
+                    matching_key = key_name
+                    break
+    except OSError:
+        pass
+    if matching_key is not None:
+        return matching_key
+
+
+def get_value_from_tuple(value, value_type):
+    try:
+        import winreg
+    except ImportError:
+        import _winreg as winreg
+    if value_type in (winreg.REG_SZ, winreg.REG_EXPAND_SZ):
+        if "\0" in value:
+            return value[: value.index("\0")]
+        return value
+    return None
+
+
+def query_registry_value(root, key_name, value):
+    try:
+        import winreg
+    except ImportError:
+        import _winreg as winreg
+    try:
+        with winreg.OpenKeyEx(root, key_name, 0, winreg.KEY_READ) as key:
+            return get_value_from_tuple(*winreg.QueryValueEx(key, value))
+    except OSError:
+        return None
+
+
+def get_current_user():
+    fns = (_get_sid_from_registry, _get_sid_with_powershell)
+    for fn in fns:
+        result = fn()
+        if result:
+            return result
+    return None
diff --git a/src/vistir/backports/__init__.py b/src/vistir/backports/__init__.py
index 03859e4..b5ed656 100644
--- a/src/vistir/backports/__init__.py
+++ b/src/vistir/backports/__init__.py
@@ -2,6 +2,7 @@
 from __future__ import absolute_import, unicode_literals
 
 from .functools import partialmethod
+from .surrogateescape import register_surrogateescape
 from .tempfile import NamedTemporaryFile
 
-__all__ = ["NamedTemporaryFile", "partialmethod"]
+__all__ = ["NamedTemporaryFile", "partialmethod", "register_surrogateescape"]
diff --git a/src/vistir/backports/surrogateescape.py b/src/vistir/backports/surrogateescape.py
new file mode 100644
index 0000000..0532be0
--- /dev/null
+++ b/src/vistir/backports/surrogateescape.py
@@ -0,0 +1,196 @@
+"""
+This is Victor Stinner's pure-Python implementation of PEP 383: the "surrogateescape" error
+handler of Python 3.
+Source: misc/python/surrogateescape.py in https://bitbucket.org/haypo/misc
+"""
+
+# This code is released under the Python license and the BSD 2-clause license
+
+import codecs
+import sys
+
+import six
+
+FS_ERRORS = "surrogateescape"
+
+#     # -- Python 2/3 compatibility -------------------------------------
+#     FS_ERRORS = 'my_surrogateescape'
+
+
+def u(text):
+    if six.PY3:
+        return text
+    else:
+        return text.decode("unicode_escape")
+
+
+def b(data):
+    if six.PY3:
+        return data.encode("latin1")
+    else:
+        return data
+
+
+if six.PY3:
+    _unichr = chr
+    bytes_chr = lambda code: bytes((code,))
+else:
+    _unichr = unichr
+    bytes_chr = chr
+
+
+def surrogateescape_handler(exc):
+    """
+    Pure Python implementation of the PEP 383: the "surrogateescape" error
+    handler of Python 3. Undecodable bytes will be replaced by a Unicode
+    character U+DCxx on decoding, and these are translated into the
+    original bytes on encoding.
+    """
+    mystring = exc.object[exc.start : exc.end]
+
+    try:
+        if isinstance(exc, UnicodeDecodeError):
+            # mystring is a byte-string in this case
+            decoded = replace_surrogate_decode(mystring)
+        elif isinstance(exc, UnicodeEncodeError):
+            # In the case of u'\udcc3'.encode('ascii',
+            # 'this_surrogateescape_handler'), both Python 2.x and 3.x raise an
+            # exception anyway after this function is called, even though I think
+            # it's doing what it should. It seems that the strict encoder is called
+            # to encode the unicode string that this function returns ...
+            decoded = replace_surrogate_encode(mystring)
+        else:
+            raise exc
+    except NotASurrogateError:
+        raise exc
+    return (decoded, exc.end)
+
+
+class NotASurrogateError(Exception):
+    pass
+
+
+def replace_surrogate_encode(mystring):
+    """
+    Returns a (unicode) string, not the more logical bytes, because the codecs
+    register_error functionality expects this.
+    """
+    decoded = []
+    for ch in mystring:
+        # if utils.PY3:
+        #     code = ch
+        # else:
+        code = ord(ch)
+
+        # The following magic comes from Py3.3's Python/codecs.c file:
+        if not 0xD800 <= code <= 0xDCFF:
+            # Not a surrogate. Fail with the original exception.
+            raise NotASurrogateError
+        # mybytes = [0xe0 | (code >> 12),
+        #            0x80 | ((code >> 6) & 0x3f),
+        #            0x80 | (code & 0x3f)]
+        # Is this a good idea?
+        if 0xDC00 <= code <= 0xDC7F:
+            decoded.append(_unichr(code - 0xDC00))
+        elif code <= 0xDCFF:
+            decoded.append(_unichr(code - 0xDC00))
+        else:
+            raise NotASurrogateError
+    return str().join(decoded)
+
+
+def replace_surrogate_decode(mybytes):
+    """
+    Returns a (unicode) string
+    """
+    decoded = []
+    for ch in mybytes:
+        # We may be parsing newbytes (in which case ch is an int) or a native
+        # str on Py2
+        if isinstance(ch, int):
+            code = ch
+        else:
+            code = ord(ch)
+        if 0x80 <= code <= 0xFF:
+            decoded.append(_unichr(0xDC00 + code))
+        elif code <= 0x7F:
+            decoded.append(_unichr(code))
+        else:
+            # # It may be a bad byte
+            # # Try swallowing it.
+            # continue
+            # print("RAISE!")
+            raise NotASurrogateError
+    return str().join(decoded)
+
+
+def encodefilename(fn):
+    if FS_ENCODING == "ascii":
+        # ASCII encoder of Python 2 expects that the error handler returns a
+        # Unicode string encodable to ASCII, whereas our surrogateescape error
+        # handler has to return bytes in 0x80-0xFF range.
+        encoded = []
+        for index, ch in enumerate(fn):
+            code = ord(ch)
+            if code < 128:
+                ch = bytes_chr(code)
+            elif 0xDC80 <= code <= 0xDCFF:
+                ch = bytes_chr(code - 0xDC00)
+            else:
+                raise UnicodeEncodeError(
+                    FS_ENCODING, fn, index, index + 1, "ordinal not in range(128)"
+                )
+            encoded.append(ch)
+        return bytes().join(encoded)
+    elif FS_ENCODING == "utf-8":
+        # UTF-8 encoder of Python 2 encodes surrogates, so U+DC80-U+DCFF
+        # doesn't go through our error handler
+        encoded = []
+        for index, ch in enumerate(fn):
+            code = ord(ch)
+            if 0xD800 <= code <= 0xDFFF:
+                if 0xDC80 <= code <= 0xDCFF:
+                    ch = bytes_chr(code - 0xDC00)
+                    encoded.append(ch)
+                else:
+                    raise UnicodeEncodeError(
+                        FS_ENCODING, fn, index, index + 1, "surrogates not allowed"
+                    )
+            else:
+                ch_utf8 = ch.encode("utf-8")
+                encoded.append(ch_utf8)
+        return bytes().join(encoded)
+    else:
+        return fn.encode(FS_ENCODING, FS_ERRORS)
+
+
+def decodefilename(fn):
+    return fn.decode(FS_ENCODING, FS_ERRORS)
+
+
+FS_ENCODING = "ascii"
+fn = b("[abc\xff]")
+encoded = u("[abc\udcff]")
+# FS_ENCODING = 'cp932'; fn = b('[abc\x81\x00]'); encoded = u('[abc\udc81\x00]')
+# FS_ENCODING = 'UTF-8'; fn = b('[abc\xff]'); encoded = u('[abc\udcff]')
+
+
+# normalize the filesystem encoding name.
+# For example, we expect "utf-8", not "UTF8".
+FS_ENCODING = codecs.lookup(FS_ENCODING).name
+
+
+def register_surrogateescape():
+    """
+    Registers the surrogateescape error handler on Python 2 (only)
+    """
+    if six.PY3:
+        return
+    try:
+        codecs.lookup_error(FS_ERRORS)
+    except LookupError:
+        codecs.register_error(FS_ERRORS, surrogateescape_handler)
+
+
+if __name__ == "__main__":
+    pass
diff --git a/src/vistir/compat.py b/src/vistir/compat.py
index ed1f256..417a785 100644
--- a/src/vistir/compat.py
+++ b/src/vistir/compat.py
@@ -56,7 +56,9 @@ else:
     from backports.functools_lru_cache import lru_cache
     from .backports.functools import partialmethod  # type: ignore
     from backports.shutil_get_terminal_size import get_terminal_size
+    from .backports.surrogateescape import register_surrogateescape
 
+    register_surrogateescape()
     NamedTemporaryFile = _NamedTemporaryFile
     from backports.weakref import finalize  # type: ignore
 
@@ -245,6 +247,72 @@ def _get_path(path):
     return
 
 
+# copied from the os backport which in turn copied this from
+# the pyutf8 package --
+# URL: https://github.com/etrepum/pyutf8/blob/master/pyutf8/ref.py
+#
+def _invalid_utf8_indexes(bytes):
+    skips = []
+    i = 0
+    len_bytes = len(bytes)
+    while i < len_bytes:
+        c1 = bytes[i]
+        if c1 < 0x80:
+            # U+0000 - U+007F - 7 bits
+            i += 1
+            continue
+        try:
+            c2 = bytes[i + 1]
+            if (c1 & 0xE0 == 0xC0) and (c2 & 0xC0 == 0x80):
+                # U+0080 - U+07FF - 11 bits
+                c = ((c1 & 0x1F) << 6) | (c2 & 0x3F)
+                if c < 0x80:  # pragma: no cover
+                    # Overlong encoding
+                    skips.extend([i, i + 1])  # pragma: no cover
+                i += 2
+                continue
+            c3 = bytes[i + 2]
+            if (c1 & 0xF0 == 0xE0) and (c2 & 0xC0 == 0x80) and (c3 & 0xC0 == 0x80):
+                # U+0800 - U+FFFF - 16 bits
+                c = ((((c1 & 0x0F) << 6) | (c2 & 0x3F)) << 6) | (c3 & 0x3F)
+                if (c < 0x800) or (0xD800 <= c <= 0xDFFF):
+                    # Overlong encoding or surrogate.
+                    skips.extend([i, i + 1, i + 2])
+                i += 3
+                continue
+            c4 = bytes[i + 3]
+            if (
+                (c1 & 0xF8 == 0xF0)
+                and (c2 & 0xC0 == 0x80)
+                and (c3 & 0xC0 == 0x80)
+                and (c4 & 0xC0 == 0x80)
+            ):
+                # U+10000 - U+10FFFF - 21 bits
+                c = ((((((c1 & 0x0F) << 6) | (c2 & 0x3F)) << 6) | (c3 & 0x3F)) << 6) | (
+                    c4 & 0x3F
+                )
+                if (c < 0x10000) or (c > 0x10FFFF):  # pragma: no cover
+                    # Overlong encoding or invalid code point.
+                    skips.extend([i, i + 1, i + 2, i + 3])
+                i += 4
+                continue
+        except IndexError:
+            pass
+        skips.append(i)
+        i += 1
+    return skips
+
+
+# XXX backport: Another helper to support the Python 2 UTF-8 decoding hack.
+def _chunks(b, indexes):
+    i = 0
+    for j in indexes:
+        yield b[i:j]
+        yield b[j : j + 1]
+        i = j + 1
+    yield b[i:]
+
+
 def fs_encode(path):
     """
     Encode a filesystem path to the proper filesystem encoding
@@ -257,7 +325,16 @@ def fs_encode(path):
     if path is None:
         raise TypeError("expected a valid path to encode")
     if isinstance(path, six.text_type):
-        path = path.encode(_fs_encoding, _fs_encode_errors)
+        if six.PY2:
+            return b"".join(
+                (
+                    _byte(ord(c) - 0xDC00)
+                    if 0xDC00 <= ord(c) <= 0xDCFF
+                    else c.encode(_fs_encoding, _fs_encode_errors)
+                )
+                for c in path
+            )
+        return path.encode(_fs_encoding, _fs_encode_errors)
     return path
 
 
@@ -266,35 +343,49 @@ def fs_decode(path):
     Decode a filesystem path using the proper filesystem encoding
 
     :param path: The filesystem path to decode from bytes or string
-    :return: [description]
-    :rtype: [type]
+    :return: The filesystem path, decoded with the determined encoding
+    :rtype: Text
     """
 
     path = _get_path(path)
     if path is None:
         raise TypeError("expected a valid path to decode")
     if isinstance(path, six.binary_type):
-        path = path.decode(_fs_encoding, _fs_decode_errors)
+        if six.PY2:
+            from array import array
+
+            indexes = _invalid_utf8_indexes(array(str("B"), path))
+            return "".join(
+                chunk.decode(_fs_encoding, _fs_decode_errors)
+                for chunk in _chunks(path, indexes)
+            )
+        return path.decode(_fs_encoding, _fs_decode_errors)
     return path
 
 
-if sys.version_info >= (3, 3) and os.name != "nt":
-    _fs_encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
+if sys.version_info[0] < 3:
+    _fs_encode_errors = "surrogateescape"
+    _fs_decode_errors = "surrogateescape"
+    _fs_encoding = "utf-8"
 else:
     _fs_encoding = "utf-8"
-
-if six.PY3:
-    if os.name == "nt":
+    if sys.platform.startswith("win"):
         _fs_error_fn = None
-        alt_strategy = "surrogatepass"
+        if sys.version_info[:2] > (3, 4):
+            alt_strategy = "surrogatepass"
+        else:
+            alt_strategy = "surrogateescape"
     else:
+        if sys.version_info >= (3, 3):
+            _fs_encoding = sys.getfilesystemencoding()
+            if not _fs_encoding:
+                _fs_encoding = sys.getdefaultencoding()
         alt_strategy = "surrogateescape"
         _fs_error_fn = getattr(sys, "getfilesystemencodeerrors", None)
-    _fs_encode_errors = _fs_error_fn() if _fs_error_fn is not None else alt_strategy
-    _fs_decode_errors = _fs_error_fn() if _fs_error_fn is not None else alt_strategy
-else:
-    _fs_encode_errors = "backslashreplace"
-    _fs_decode_errors = "replace"
+    _fs_encode_errors = _fs_error_fn() if _fs_error_fn else alt_strategy
+    _fs_decode_errors = _fs_error_fn() if _fs_error_fn else alt_strategy
+
+_byte = chr if sys.version_info < (3,) else lambda i: bytes([i])
 
 
 def to_native_string(string):
diff --git a/src/vistir/misc.py b/src/vistir/misc.py
index 63f7dc5..ae72686 100644
--- a/src/vistir/misc.py
+++ b/src/vistir/misc.py
@@ -222,13 +222,15 @@ def _create_subprocess(
         c = _spawn_subprocess(
             cmd, env=env, block=block, cwd=cwd, combine_stderr=combine_stderr
         )
-    except Exception:
+    except Exception as exc:
         import traceback
 
-        formatted_tb = "".join(traceback.format_exception(*sys.exc_info()))
-        sys.stderr.write("Error while executing command %s:" % " ".join(cmd._parts))
-        sys.stderr.write(formatted_tb)
-        raise
+        formatted_tb = "".join(traceback.format_exception(*sys.exc_info()))  # pragma: no cover
+        sys.stderr.write(  # pragma: no cover
+            "Error while executing command %s:" % to_native_string(" ".join(cmd._parts))  # pragma: no cover
+        )  # pragma: no cover
+        sys.stderr.write(formatted_tb)  # pragma: no cover
+        raise exc  # pragma: no cover
     if not block:
         c.stdin.close()
         spinner_orig_text = ""
@@ -397,14 +399,14 @@ def partialclass(cls, *args, **kwargs):
     # Swiped from attrs.make_class
     try:
         type_.__module__ = sys._getframe(1).f_globals.get("__name__", "__main__")
-    except (AttributeError, ValueError):
-        pass
+    except (AttributeError, ValueError):  # pragma: no cover
+        pass  # pragma: no cover
     return type_
 
 
 # Borrowed from django -- force bytes and decode -- see link for details:
 # https://github.com/django/django/blob/fc6b90b/django/utils/encoding.py#L112
-def to_bytes(string, encoding="utf-8", errors="ignore"):
+def to_bytes(string, encoding="utf-8", errors=None):
     """Force a value to bytes.
 
     :param string: Some input that can be converted to a bytes.
@@ -415,16 +417,20 @@ def to_bytes(string, encoding="utf-8", errors="ignore"):
     :rtype: bytes
     """
 
+    unicode_name = get_canonical_encoding_name("utf-8")
     if not errors:
-        if encoding.lower() == "utf-8":
-            errors = "surrogateescape" if six.PY3 else "ignore"
+        if get_canonical_encoding_name(encoding) == unicode_name:
+            if six.PY3 and os.name == "nt":
+                errors = "surrogatepass"
+            else:
+                errors = "surrogateescape" if six.PY3 else "ignore"
         else:
             errors = "strict"
     if isinstance(string, bytes):
-        if encoding.lower() == "utf-8":
+        if get_canonical_encoding_name(encoding) == unicode_name:
             return string
         else:
-            return string.decode("utf-8").encode(encoding, errors)
+            return string.decode(unicode_name).encode(encoding, errors)
     elif isinstance(string, memoryview):
         return bytes(string)
     elif not isinstance(string, six.string_types):
@@ -452,9 +458,13 @@ def to_text(string, encoding="utf-8", errors=None):
     :rtype: str
     """
 
+    unicode_name = get_canonical_encoding_name("utf-8")
     if not errors:
-        if encoding.lower() == "utf-8":
-            errors = "surrogateescape" if six.PY3 else "ignore"
+        if get_canonical_encoding_name(encoding) == unicode_name:
+            if six.PY3 and os.name == "nt":
+                errors = "surrogatepass"
+            else:
+                errors = "surrogateescape" if six.PY3 else "ignore"
         else:
             errors = "strict"
     if issubclass(type(string), six.text_type):
@@ -801,17 +811,16 @@ _color_stream_cache = WeakKeyDictionary()
 
 if os.name == "nt" or sys.platform.startswith("win"):
 
-    def _wrap_for_color(stream, allow_color=True):
-        if colorama is not None:
+    if colorama is not None:
+        def _wrap_for_color(stream, color=None):
             try:
                 cached = _color_stream_cache.get(stream)
             except KeyError:
                 cached = None
             if cached is not None:
                 return cached
-            if not _isatty(stream):
-                allow_color = False
-            _color_wrapper = colorama.AnsiToWin32(stream, strip=not allow_color)
+            strip = not _can_use_color(stream, color)
+            _color_wrapper = colorama.AnsiToWin32(stream, strip=strip)
             result = _color_wrapper.stream
             _write = result.write
 
@@ -829,8 +838,6 @@ if os.name == "nt" or sys.platform.startswith("win"):
                 pass
             return result
 
-        return stream
-
 
 def _cached_stream_lookup(stream_lookup_func, stream_resolution_func):
     stream_cache = WeakKeyDictionary()
@@ -853,7 +860,7 @@ def _cached_stream_lookup(stream_lookup_func, stream_resolution_func):
     return lookup
 
 
-def get_text_stream(stream="stdout", encoding=None, allow_color=True):
+def get_text_stream(stream="stdout", encoding=None):
     """Retrieve a unicode stream wrapper around **sys.stdout** or **sys.stderr**.
 
     :param str stream: The name of the stream to wrap from the :mod:`sys` module.
@@ -916,15 +923,19 @@ def replace_with_text_stream(stream_name):
     return None
 
 
-def _can_use_color(stream=None, fg=None, bg=None, style=None):
-    if not any([fg, bg, style]):
+def _can_use_color(stream=None, color=None):
+    from .termcolors import DISABLE_COLORS
+
+    if DISABLE_COLORS:
+        return False
+    if not color:
         if not stream:
             stream = sys.stdin
         return _isatty(stream)
-    return any([fg, bg, style])
+    return bool(color)
 
 
-def echo(text, fg=None, bg=None, style=None, file=None, err=False):
+def echo(text, fg=None, bg=None, style=None, file=None, err=False, color=None):
     """Write the given text to the provided stream or **sys.stdout** by default.
 
     Provides optional foreground and background colors from the ansi defaults:
@@ -939,6 +950,7 @@ def echo(text, fg=None, bg=None, style=None, file=None, err=False):
     :param str bg: Foreground color to use (default: None)
     :param str style: Style to use (default: None)
     :param stream file: File to write to (default: None)
+    :param bool color: Whether to force color (i.e. ANSI codes are in the text)
     """
 
     if file and not hasattr(file, "write"):
@@ -963,12 +975,13 @@ def echo(text, fg=None, bg=None, style=None, file=None, err=False):
             buffer.flush()
             return
     if text and not is_bytes(text):
-        can_use_color = _can_use_color(file, fg=fg, bg=bg, style=style)
-        if os.name == "nt":
+        can_use_color = _can_use_color(file, color=color)
+        if any([fg, bg, style]):
             text = colorize(text, fg=fg, bg=bg, attrs=style)
-            file = _wrap_for_color(file, allow_color=can_use_color)
-        elif not can_use_color:
+        if not can_use_color or (os.name == "nt" and not _wrap_for_color):
             text = ANSI_REMOVAL_RE.sub("", text)
+        elif os.name == "nt" and _wrap_for_color:
+            file = _wrap_for_color(file, color=color)
     if text:
         file.write(text)
     file.flush()
diff --git a/src/vistir/path.py b/src/vistir/path.py
index 71d36f1..8ea408f 100644
--- a/src/vistir/path.py
+++ b/src/vistir/path.py
@@ -306,6 +306,22 @@ def create_tracked_tempfile(*args, **kwargs):
     return _NamedTemporaryFile(*args, **kwargs)
 
 
+def _find_icacls_exe():
+    if os.name == "nt":
+        paths = [
+            os.path.expandvars(r"%windir%\{0}").format(subdir)
+            for subdir in ("system32", "SysWOW64")
+        ]
+        for path in paths:
+            icacls_path = next(
+                iter(fn for fn in os.listdir(path) if fn.lower() == "icacls.exe"), None
+            )
+            if icacls_path is not None:
+                icacls_path = os.path.join(path, icacls_path)
+                return icacls_path
+    return None
+
+
 def set_write_bit(fn):
     # type: (str) -> None
     """
@@ -321,6 +337,17 @@ def set_write_bit(fn):
         return
     file_stat = os.stat(fn).st_mode
     os.chmod(fn, file_stat | stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
+    if os.name == "nt":
+        from ._winconsole import get_current_user
+
+        user_sid = get_current_user()
+        icacls_exe = _find_icacls_exe() or "icacls"
+        from .misc import run
+        if user_sid:
+            _, err = run([icacls_exe, "/grant", "{0}:WD".format(user_sid), "''{0}''".format(fn), "/T", "/C", "/Q"])
+            if not err:
+                return
+
     if not os.path.isdir(fn):
         for path in [fn, os.path.dirname(fn)]:
             try:

Windows chmod ignores anything besides read permissions
From the [chmod documentation](https://docs.python.org/3.7/library/os.html#os.chmod)

>Note
>
>Although Windows supports chmod(), you can only set the file’s read-only flag with it (via the stat.S_IWRITE and stat.S_IREAD constants or a corresponding integer value). All other bits are ignored. 
diff --git a/tests/test_misc.py b/tests/test_misc.py
index e364267..35b2d10 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -88,6 +88,12 @@ def test_run_return_subprocess():
     assert c.out.strip() == "test"
 
 
+def test_run_failing_subprocess(capsys):
+    c = vistir.misc.run(["fakecommandthatdoesntexist", "fake", "argument"], nospin=True, return_object=True, block=False)
+    out, err = capsys.readouterr()
+    assert "FAIL" in out
+
+
 def test_run_with_long_output():
     long_str = "this is a very long string which exceeds the maximum length per the settings we are passing in to vistir"
     print_cmd = "import time; print('{0}'); time.sleep(2)".format(long_str)
@@ -200,7 +206,7 @@ def test_stream_wrapper(capsys):
 
 
 def test_colorized_stream(capsys):
-    new_stream = vistir.misc.get_text_stream("stdout", allow_color=True)
+    new_stream = vistir.misc.get_text_stream("stdout")
     sys.stdout = new_stream
     green_string = "\x1b[32m\x1b[22mhello\x1b[39m\x1b[22m"
     print(green_string, file=sys.stdout)
@@ -213,15 +219,38 @@ def test_colorized_stream(capsys):
     assert "hello" in out
 
 
-def test_strip_colors(capsys):
+def test_strip_colors(capsys, monkeypatch):
+    with monkeypatch.context() as m:
+        m.setattr("vistir.termcolors.DISABLE_COLORS", True)
+        sys.stdout = vistir.misc.get_text_stream("stdout")
+        sys.stdin = vistir.misc.get_text_stream("stdin")
+        vistir.misc.echo("hello", fg="green")
+        out, _ = capsys.readouterr()
+        assert "\x1b[32m" not in out
+        assert "hello" in out
+        green_string = "\x1b[32m\x1b[22mhello\x1b[39m\x1b[22m"
+        vistir.misc.echo(green_string)
+        out, _ = capsys.readouterr()
+        assert "hello" in out
+        assert "\x1b[32m" not in out
+
+
+@pytest.mark.skipif(sys.version_info[0] < 3, reason="Python 2 uses bytes by default")
+def test_write_bytes(capsys):
     sys.stdout = vistir.misc.get_text_stream("stdout")
-    sys.stdin = vistir.misc.get_text_stream("stdin", allow_color=False)
-    vistir.misc.echo("hello", fg="green")
+    sys.stdin = vistir.misc.get_text_stream("stdin")
+    vistir.misc.echo(vistir.misc.to_bytes("hello"), fg="green")
     out, _ = capsys.readouterr()
     assert "\x1b[32m" not in out
     assert "hello" in out
-    green_string = "\x1b[32m\x1b[22mhello\x1b[39m\x1b[22m"
+    green_string = vistir.misc.to_bytes("\x1b[32m\x1b[22mhello\x1b[39m\x1b[22m")
     vistir.misc.echo(green_string)
     out, _ = capsys.readouterr()
     assert "hello" in out
-    assert "\x1b[32m" not in out
+    assert "\x1b[32m" in out
+    # now add color=True to make sure this works right
+    green_string = vistir.misc.to_bytes("\x1b[32m\x1b[22mhello\x1b[39m\x1b[22m")
+    vistir.misc.echo(green_string, color=True)
+    out, _ = capsys.readouterr()
+    assert "hello" in out
+    assert "\x1b[32m" in out
diff --git a/tests/test_path.py b/tests/test_path.py
index 6a3575d..24c8352 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -5,6 +5,7 @@ import io
 import os
 import stat
 
+import pytest
 from hypothesis import HealthCheck, assume, example, given, settings
 from hypothesis_fspaths import fspaths
 from six.moves.urllib import parse as urllib_parse
@@ -24,6 +25,31 @@ def test_abspathu(tmpdir):
         )
 
 
+def test_normalize_path():
+    with vistir.contextmanagers.temp_environ():
+        os.environ["PATH_VAR"] = "some_path"
+        try:
+            assert os.environ.get("HOME")
+        except AssertionError:
+            os.environ["HOME"] = os.getcwd()
+        orig_path = os.path.normcase(
+            str(vistir.compat.Path("~").expanduser() / "some_path" / "other_path")
+        )
+        assert vistir.path.normalize_path("~/${PATH_VAR}/other_path") == orig_path
+
+
+@pytest.mark.parametrize(
+    "path, root, result",
+    [
+        ("~/some/path/child", "~/some/path", True),
+        ("~/some", "~/some/path", False),
+        ("~/some/path/child", "~", True),
+    ],
+)
+def test_is_in_path(path, root, result):
+    assert vistir.path.is_in_path(path, root) == result
+
+
 def test_safe_expandvars():
     with vistir.contextmanagers.temp_environ():
         os.environ["TEST_VAR"] = "some_password"
@@ -128,7 +154,7 @@ def test_is_valid_url(url):
 @given(fspaths())
 @settings(suppress_health_check=(HealthCheck.filter_too_much,))
 def test_path_to_url(filepath):
-    filename = vistir.misc.to_text(filepath)
+    filename = vistir.compat.fs_decode(filepath)
     if filepath and filename:
         assume(any(letter in filename for letter in url_alphabet))
     file_url = vistir.path.path_to_url(filename)

pip install -e .[dev,spinner,tests]
parse_log_pytest
Pipfile
pytest
apt-get update apt-get install -y gcc
3.7
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==24.2.0
black==23.3.0
bleach==6.0.0
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
cfgv==3.3.1
charset-normalizer==3.4.1
click==8.1.8
cmarkgfm==2024.11.20
colorama==0.4.6
coverage==7.2.7
cryptography==44.0.2
distlib==0.3.9
docutils==0.20.1
exceptiongroup==1.2.2
execnet==2.0.2
filelock==3.12.2
flake8==5.0.4
flake8-bugbear==23.3.12
hypothesis==6.79.4
hypothesis-fspaths==0.1
identify==2.5.24
idna==3.10
importlib-metadata==4.2.0
iniconfig==2.0.0
isort==5.11.5
jaraco.classes==3.2.3
jeepney==0.9.0
keyring==23.9.3
markdown-it-py==2.2.0
mccabe==0.7.0
mdurl==0.1.2
more-itertools==9.1.0
mypy==1.4.1
mypy-extensions==1.0.0
nodeenv==1.9.1
packaging==24.0
pathspec==0.11.2
pipfile==0.0.2
pkginfo==1.10.0
platformdirs==2.6.2
pluggy==1.2.0
pre-commit==2.21.0
pycodestyle==2.9.1
pycparser==2.21
pyflakes==2.5.0
Pygments==2.17.2
pytest==7.4.4
pytest-cov==4.1.0
pytest-timeout==2.3.1
pytest-xdist==3.5.0
pytoolconfig==1.3.0
PyYAML==6.0.1
readme-renderer==37.3
requests==2.31.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==13.8.1
rope==1.9.0
SecretStorage==3.3.3
six==1.17.0
sortedcontainers==2.4.0
termcolor==2.3.0
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
tomli==2.0.1
twine==4.0.2
typed-ast==1.5.5
typing_extensions==4.7.1
urllib3==2.0.7
virtualenv==20.16.2
-e git+https://github.com/sarugaku/vistir.git@69ac14ed6288e53e0abc2afa796104ee8bc0d49b#egg=vistir
webencodings==0.5.1
yaspin==2.4.0
zipp==3.15.0

name: vistir
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - pipfile=0.0.2=py_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==24.2.0
      - black==23.3.0
      - bleach==6.0.0
      - cffi==1.15.1
      - cfgv==3.3.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - cmarkgfm==2024.11.20
      - colorama==0.4.6
      - coverage==7.2.7
      - cryptography==44.0.2
      - distlib==0.3.9
      - docutils==0.20.1
      - exceptiongroup==1.2.2
      - execnet==2.0.2
      - filelock==3.12.2
      - flake8==5.0.4
      - flake8-bugbear==23.3.12
      - hypothesis==6.79.4
      - hypothesis-fspaths==0.1
      - identify==2.5.24
      - idna==3.10
      - importlib-metadata==4.2.0
      - iniconfig==2.0.0
      - isort==5.11.5
      - jaraco-classes==3.2.3
      - jeepney==0.9.0
      - keyring==23.9.3
      - markdown-it-py==2.2.0
      - mccabe==0.7.0
      - mdurl==0.1.2
      - more-itertools==9.1.0
      - mypy==1.4.1
      - mypy-extensions==1.0.0
      - nodeenv==1.9.1
      - packaging==24.0
      - pathspec==0.11.2
      - pkginfo==1.10.0
      - platformdirs==2.6.2
      - pluggy==1.2.0
      - pre-commit==2.21.0
      - pycodestyle==2.9.1
      - pycparser==2.21
      - pyflakes==2.5.0
      - pygments==2.17.2
      - pytest==7.4.4
      - pytest-cov==4.1.0
      - pytest-timeout==2.3.1
      - pytest-xdist==3.5.0
      - pytoolconfig==1.3.0
      - pyyaml==6.0.1
      - readme-renderer==37.3
      - requests==2.31.0
      - requests-toolbelt==1.0.0
      - rfc3986==2.0.0
      - rich==13.8.1
      - rope==1.9.0
      - secretstorage==3.3.3
      - six==1.17.0
      - sortedcontainers==2.4.0
      - termcolor==2.3.0
      - tomli==2.0.1
      - twine==4.0.2
      - typed-ast==1.5.5
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - virtualenv==20.16.2
      - vistir==0.4.0
      - webencodings==0.5.1
      - yaspin==2.4.0
      - zipp==3.15.0
prefix: /opt/conda/envs/vistir

tests/test_misc.py::test_write_bytes
tests/test_misc.py::test_shell_escape tests/test_misc.py::test_unnest tests/test_misc.py::test_dedup tests/test_misc.py::test_run tests/test_misc.py::test_run_return_subprocess tests/test_misc.py::test_run_failing_subprocess tests/test_misc.py::test_run_with_long_output tests/test_misc.py::test_nonblocking_run tests/test_misc.py::test_load_path tests/test_misc.py::test_partialclass tests/test_misc.py::test_divide[1-iterable0-expected0] tests/test_misc.py::test_divide[2-iterable1-expected1] tests/test_misc.py::test_divide[3-iterable2-expected2] tests/test_misc.py::test_divide[10-iterable3-expected3] tests/test_misc.py::test_divide[6-iterable4-expected4] tests/test_misc.py::test_decode_encode tests/test_misc.py::test_wrapped_stream[this tests/test_misc.py::test_wrapped_stream[unicode\u0141] tests/test_misc.py::test_wrapped_stream[latin\xe9] tests/test_misc.py::test_stream_wrapper tests/test_misc.py::test_colorized_stream tests/test_misc.py::test_strip_colors tests/test_path.py::test_abspathu tests/test_path.py::test_normalize_path tests/test_path.py::test_is_in_path[~/some/path/child-~/some/path-True] tests/test_path.py::test_is_in_path[~/some-~/some/path-False] tests/test_path.py::test_is_in_path[~/some/path/child-~-True] tests/test_path.py::test_safe_expandvars tests/test_path.py::test_mkdir_p tests/test_path.py::test_ensure_mkdir_p tests/test_path.py::test_create_tracked_tempdir tests/test_path.py::test_rmtree tests/test_path.py::test_is_readonly_path tests/test_path.py::test_get_converted_relative_path tests/test_path.py::test_is_valid_url tests/test_path.py::test_path_to_url tests/test_path.py::test_normalize_drive tests/test_path.py::test_walk_up tests/test_path.py::test_handle_remove_readonly
src/vistir/_winconsole.py news/81.feature.rst src/vistir/backports/__init__.py src/vistir/compat.py src/vistir/misc.py src/vistir/path.py src/vistir/backports/surrogateescape.py .gitignore setup.cfg .coveragerc news/82.bugfix.rst
src/vistir/_winconsole.py news/81.feature.rst src/vistir/backports/__init__.py src/vistir/compat.py src/vistir/misc.py src/vistir/path.py src/vistir/backports/surrogateescape.py .gitignore setup.cfg .coveragerc news/82.bugfix.rst