Repository: pre-commit/pre-commit

Issue:
UnicodeEncodeError when writing to stdout in python2.6 
```
$ pre-commit run fixmyjs
fixmyjs............................................................................................................................................................................................Failed
hookid: fixmyjs

Traceback (most recent call last):
  File "virtualenv_run/bin/pre-commit", line 14, in <module>
    sys.exit(main())
  File "virtualenv_run/lib/python2.6/site-packages/pre_commit/util.py", line 41, in wrapper
    return func(argv)
  File "virtualenv_run/lib/python2.6/site-packages/pre_commit/main.py", line 99, in main
    return run(runner, args)
  File "virtualenv_run/lib/python2.6/site-packages/pre_commit/commands/run.py", line 144, in run
    return _run_hook(runner, args, write=write)
  File "virtualenv_run/lib/python2.6/site-packages/pre_commit/commands/run.py", line 116, in _run_hook
    return _run_single_hook(runner, repo, hook_id, args, write=write)
  File "virtualenv_run/lib/python2.6/site-packages/pre_commit/commands/run.py", line 91, in _run_single_hook
    write(output.strip() + '\n')
UnicodeEncodeError: 'ascii' codec can't encode character u'\u2713' in position 0: ordinal not in range(128)
```



Discussion:
This SO post echos this bug:
http://stackoverflow.com/questions/8016236/python-unicode-handling-differences-between-print-and-sys-stdout-write

Sadly:

```
$ python2.6 -c "import sys; sys.stdout.write(u'\u2603\n')"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character u'\u2603' in position 0: ordinal not in range(128)
$ python2.7 -c "import sys; sys.stdout.write(u'\u2603\n')"
☃
$ python3.3 -c "import sys; sys.stdout.write(u'\u2603\n')"
☃
$ python3.4 -c "import sys; sys.stdout.write(u'\u2603\n')"
☃
```

And using bytes:

```
$ python2.6 -c "import sys; sys.stdout.write(u'\u2603\n'.encode('UTF-8'))"
☃
$ python2.7 -c "import sys; sys.stdout.write(u'\u2603\n'.encode('UTF-8'))"
☃
$ python3.3 -c "import sys; sys.stdout.write(u'\u2603\n'.encode('UTF-8'))"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: must be str, not bytes
$ python3.4 -c "import sys; sys.stdout.write(u'\u2603\n'.encode('UTF-8'))"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: must be str, not bytes
```

Looks like the fix is (sadly) to convert to bytes only in the 2.6 case


Resolving patch:
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -9,6 +9,7 @@
 from pre_commit import color
 from pre_commit.logging_handler import LoggingHandler
 from pre_commit.output import get_hook_message
+from pre_commit.output import sys_stdout_write_wrapper
 from pre_commit.staged_files_only import staged_files_only
 from pre_commit.util import noop_context
 
@@ -125,7 +126,7 @@ def _has_unmerged_paths(runner):
     return bool(stdout.strip())
 
 
-def run(runner, args, write=sys.stdout.write, environ=os.environ):
+def run(runner, args, write=sys_stdout_write_wrapper, environ=os.environ):
     # Set up our logging handler
     logger.addHandler(LoggingHandler(args.color, write=write))
     logger.setLevel(logging.INFO)
diff --git a/pre_commit/output.py b/pre_commit/output.py
--- a/pre_commit/output.py
+++ b/pre_commit/output.py
@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 
 import subprocess
+import sys
 
 from pre_commit import color
+from pre_commit import five
 
 
 # TODO: smell: import side-effects
@@ -70,3 +72,14 @@ def get_hook_message(
             postfix,
             color.format_color(end_msg, end_color, use_color),
         )
+
+
+def sys_stdout_write_wrapper(s, stream=sys.stdout):
+    """Python 2.6 chokes on unicode being passed to sys.stdout.write.
+
+    This is an adapter because PY2 is ok with bytes and PY3 requires text.
+    """
+    assert type(s) is five.text
+    if five.PY2:  # pragma: no cover (PY2)
+        s = s.encode('UTF-8')
+    stream.write(s)


Test patch:
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -1,3 +1,4 @@
+# -*- coding: UTF-8 -*-
 from __future__ import unicode_literals
 
 import io
@@ -5,8 +6,10 @@
 import os
 import os.path
 import pytest
+import subprocess
 from plumbum import local
 
+from pre_commit.commands.install_uninstall import install
 from pre_commit.commands.run import _get_skips
 from pre_commit.commands.run import _has_unmerged_paths
 from pre_commit.commands.run import run
@@ -225,3 +228,30 @@ def test_multiple_hooks_same_id(
     ret, output = _do_run(repo_with_passing_hook, _get_opts())
     assert ret == 0
     assert output.count('Bash hook') == 2
+
+
+def test_stdout_write_bug_py26(
+        repo_with_failing_hook, mock_out_store_directory, tmpdir_factory,
+):
+    with local.cwd(repo_with_failing_hook):
+        # Add bash hook on there again
+        with io.open('.pre-commit-config.yaml', 'a+') as config_file:
+            config_file.write('        args: ["☃"]\n')
+        local['git']('add', '.pre-commit-config.yaml')
+        stage_a_file()
+
+        install(Runner(repo_with_failing_hook))
+
+        # Don't want to write to home directory
+        env = dict(os.environ, **{'PRE_COMMIT_HOME': tmpdir_factory.get()})
+        # Have to use subprocess because pytest monkeypatches sys.stdout
+        _, stdout, _ = local['git'].run(
+            ('commit', '-m', 'Commit!'),
+            # git commit puts pre-commit to stderr
+            stderr=subprocess.STDOUT,
+            env=env,
+            retcode=None,
+        )
+        assert 'UnicodeEncodeError' not in stdout
+        # Doesn't actually happen, but a reasonable assertion
+        assert 'UnicodeDecodeError' not in stdout
diff --git a/tests/output_test.py b/tests/output_test.py
--- a/tests/output_test.py
+++ b/tests/output_test.py
@@ -1,9 +1,11 @@
 from __future__ import unicode_literals
 
+import mock
 import pytest
 
 from pre_commit import color
 from pre_commit.output import get_hook_message
+from pre_commit.output import sys_stdout_write_wrapper
 
 
 @pytest.mark.parametrize(
@@ -77,3 +79,9 @@ def test_make_sure_postfix_is_not_colored():
     assert ret == (
         'start' + '.' * 6 + 'post ' + color.RED + 'end' + color.NORMAL + '\n'
     )
+
+
+def test_sys_stdout_write_wrapper_writes():
+    fake_stream = mock.Mock()
+    sys_stdout_write_wrapper('hello world', fake_stream)
+    assert fake_stream.write.call_count == 1