Repository: buildbot/buildbot

Issue:
buildbot try fails for files containing non utf-8 characters
buildbot try fails for files containing non utf-8 characters, i.e. different encoding.

e.g.:
```
[~]$buildbot try --connect=pb --master=master-name:1234 --username=buildbot_try --passwd=test --diff=testfile.patch --repository=''
using 'pb' connect method
Unhandled Error
Traceback (most recent call last):
  File "/usr/local/lib64/python3.6/site-packages/twisted/internet/base.py", line 913, in runUntilCurrent
    call.func(*call.args, **call.kw)
  File "/usr/local/lib/python3.6/site-packages/buildbot/util/eventual.py", line 48, in _turn
    cb(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/twisted/internet/defer.py", line 460, in callback
    self._startRunCallbacks(result)
  File "/usr/local/lib64/python3.6/site-packages/twisted/internet/defer.py", line 568, in _startRunCallbacks
    self._runCallbacks()
--- <exception caught here> ---
  File "/usr/local/lib64/python3.6/site-packages/twisted/internet/defer.py", line 654, in _runCallbacks
    current.result = callback(current.result, *args, **kw)
  File "/usr/local/lib/python3.6/site-packages/buildbot/clients/tryclient.py", line 937, in trapSystemExit
    why.trap(SystemExit)
  File "/usr/local/lib64/python3.6/site-packages/twisted/python/failure.py", line 460, in trap
    self.raiseException()
  File "/usr/local/lib64/python3.6/site-packages/twisted/python/failure.py", line 488, in raiseException
    raise self.value.with_traceback(self.tb)
  File "/usr/local/lib64/python3.6/site-packages/twisted/internet/defer.py", line 654, in _runCallbacks
    current.result = callback(current.result, *args, **kw)
  File "/usr/local/lib/python3.6/site-packages/buildbot/clients/tryclient.py", line 917, in <lambda>
    d.addCallback(lambda res: self.createJob())
  File "/usr/local/lib/python3.6/site-packages/buildbot/clients/tryclient.py", line 582, in createJob
    diff = f.read()
  File "/usr/lib64/python3.6/codecs.py", line 321, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
builtins.UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 177: invalid continuation byte

[~]$cat testfile.patch
print("Ïðîâåðêà")
```


Discussion:
At https://github.com/buildbot/buildbot/blob/master/master/buildbot/clients/tryclient.py#L581, patch file is being read as string, instead it should be opened in "rb" mode, so as to read the file as bytes.

However, that alone doesn't completely fix the problem, and the code fails at subsequent step with the error: `(unable to obtain traceback): 'bytes' object has no attribute '__module__'`
@tardyp @p12tic 
A similar issue was fixed in https://github.com/buildbot/buildbot/pull/5827/files
This is a serious regression from older buildbot versions. I believe this was working fine with Python 2 in buildbot 1.8.2

Resolving patch:
diff --git a/master/buildbot/clients/tryclient.py b/master/buildbot/clients/tryclient.py
--- a/master/buildbot/clients/tryclient.py
+++ b/master/buildbot/clients/tryclient.py
@@ -13,6 +13,7 @@
 #
 # Copyright Buildbot Team Members
 
+import base64
 import json
 import os
 import random
@@ -92,7 +93,7 @@ def get(self):
     def readPatch(self, diff, patchlevel):
         if not diff:
             diff = None
-        self.patch = (patchlevel, bytes2unicode(diff))
+        self.patch = (patchlevel, diff)
 
     def done(self, res):
         if not self.repository:
@@ -281,7 +282,7 @@ def readPatch(self, res, patchlevel):
         if not found:
             output(b"could not parse patch file")
             sys.exit(1)
-        self.patch = (patchlevel, mpatch)
+        self.patch = (patchlevel, unicode2bytes(mpatch))
 
     def getPatch(self, res):
         d = self.dovc(["diff"])
@@ -450,39 +451,32 @@ def ns(s):
 def createJobfile(jobid, branch, baserev, patch_level, patch_body, repository,
                   project, who, comment, builderNames, properties):
     # Determine job file version from provided arguments
-    if properties:
+    try:
+        bytes2unicode(patch_body)
         version = 5
-    elif comment:
-        version = 4
-    elif who:
-        version = 3
-    else:
-        version = 2
+    except UnicodeDecodeError:
+        version = 6
+
     job = ""
     job += ns(str(version))
-    if version < 5:
-        job += ns(jobid)
-        job += ns(branch)
-        job += ns(str(baserev))
-        job += ns("{}".format(patch_level))
-        job += ns(patch_body or "")
-        job += ns(repository)
-        job += ns(project)
-        if (version >= 3):
-            job += ns(who)
-        if (version >= 4):
-            job += ns(comment)
-        for bn in builderNames:
-            job += ns(bn)
+    job_dict = {
+        'jobid': jobid,
+        'branch': branch,
+        'baserev': str(baserev),
+        'patch_level': patch_level,
+        'repository': repository,
+        'project': project,
+        'who': who,
+        'comment': comment,
+        'builderNames': builderNames,
+        'properties': properties,
+    }
+    if version > 5:
+        job_dict['patch_body_base64'] = bytes2unicode(base64.b64encode(patch_body))
     else:
-        job += ns(
-            json.dumps({
-                'jobid': jobid, 'branch': branch, 'baserev': str(baserev),
-                'patch_level': patch_level, 'patch_body': patch_body,
-                'repository': repository, 'project': project, 'who': who,
-                'comment': comment, 'builderNames': builderNames,
-                'properties': properties,
-            }))
+        job_dict['patch_body'] = bytes2unicode(patch_body)
+
+    job += ns(json.dumps(job_dict))
     return job
 
 
@@ -578,7 +572,7 @@ def createJob(self):
             if difffile == "-":
                 diff = sys.stdin.read()
             else:
-                with open(difffile, "r") as f:
+                with open(difffile, "rb") as f:
                     diff = f.read()
             if not diff:
                 diff = None
diff --git a/master/buildbot/schedulers/trysched.py b/master/buildbot/schedulers/trysched.py
--- a/master/buildbot/schedulers/trysched.py
+++ b/master/buildbot/schedulers/trysched.py
@@ -13,6 +13,7 @@
 #
 # Copyright Buildbot Team Members
 
+import base64
 import json
 import os
 
@@ -26,6 +27,7 @@
 from buildbot.schedulers import base
 from buildbot.util import bytes2unicode
 from buildbot.util import netstrings
+from buildbot.util import unicode2bytes
 from buildbot.util.maildir import MaildirService
 
 
@@ -135,11 +137,13 @@ def parseJob(self, f):
         #  "4" introduces comment
         #  "5" introduces properties and JSON serialization of values after
         #      version
+        #  "6" sends patch_body as base64-encoded string in the patch_body_base64 attribute
         #  jobid: arbitrary string, used to find the buildSet later
         #  branch: branch name, "" for default-branch
         #  baserev: revision, "" for HEAD
         #  patch_level: usually "1"
-        #  patch_body: patch to be applied for build
+        #  patch_body: patch to be applied for build (as string)
+        #  patch_body_base64: patch to be applied for build (as base64-encoded bytes)
         #  repository
         #  project
         #  who: user requesting build
@@ -171,7 +175,10 @@ def parseJob(self, f):
 
         def extract_netstrings(p, keys):
             for i, key in enumerate(keys):
-                parsed_job[key] = bytes2unicode(p.strings[i])
+                if key == 'patch_body':
+                    parsed_job[key] = p.strings[i]
+                else:
+                    parsed_job[key] = bytes2unicode(p.strings[i])
 
         def postprocess_parsed_job():
             # apply defaults and handle type casting
@@ -192,6 +199,16 @@ def postprocess_parsed_job():
             try:
                 data = bytes2unicode(p.strings[0])
                 parsed_job = json.loads(data)
+                parsed_job['patch_body'] = unicode2bytes(parsed_job['patch_body'])
+            except ValueError as e:
+                raise BadJobfile("unable to parse JSON") from e
+            postprocess_parsed_job()
+        elif ver == "6":
+            try:
+                data = bytes2unicode(p.strings[0])
+                parsed_job = json.loads(data)
+                parsed_job['patch_body'] = base64.b64decode(parsed_job['patch_body_base64'])
+                del parsed_job['patch_body_base64']
             except ValueError as e:
                 raise BadJobfile("unable to parse JSON") from e
             postprocess_parsed_job()
@@ -393,7 +410,8 @@ def perspective_try(self, branch, revision, patch, repository, project,
 
         branch = bytes2unicode(branch)
         revision = bytes2unicode(revision)
-        patch = patch[0], bytes2unicode(patch[1])
+        patch_level = patch[0]
+        patch_body = unicode2bytes(patch[1])
         repository = bytes2unicode(repository)
         project = bytes2unicode(project)
         who = bytes2unicode(who)
@@ -409,7 +427,7 @@ def perspective_try(self, branch, revision, patch, repository, project,
 
         sourcestamp = dict(
             branch=branch, revision=revision, repository=repository,
-            project=project, patch_level=patch[0], patch_body=patch[1],
+            project=project, patch_level=patch_level, patch_body=patch_body,
             patch_subdir='', patch_author=who or '',
             patch_comment=comment or '', codebase='',
         )           # note: no way to specify patch subdir - #1769
diff --git a/master/buildbot/steps/source/base.py b/master/buildbot/steps/source/base.py
--- a/master/buildbot/steps/source/base.py
+++ b/master/buildbot/steps/source/base.py
@@ -270,7 +270,7 @@ def run(self):
                 # root is optional.
                 patch = s.patch
                 if patch:
-                    yield self.addCompleteLog("patch", bytes2unicode(patch[1]))
+                    yield self.addCompleteLog("patch", bytes2unicode(patch[1], errors='ignore'))
             else:
                 log.msg("No sourcestamp found in build for codebase '{}'".format(self.codebase))
                 self.descriptionDone = "Codebase {} not in build".format(self.codebase)


Test patch:
diff --git a/master/buildbot/test/integration/test_integration_force_with_patch.py b/master/buildbot/test/integration/test_integration_force_with_patch.py
--- a/master/buildbot/test/integration/test_integration_force_with_patch.py
+++ b/master/buildbot/test/integration/test_integration_force_with_patch.py
@@ -23,7 +23,7 @@
 from buildbot.test.util.integration import RunMasterBase
 
 # a simple patch which adds a Makefile
-PATCH = """diff --git a/Makefile b/Makefile
+PATCH = b"""diff --git a/Makefile b/Makefile
 new file mode 100644
 index 0000000..8a5cf80
 --- /dev/null
diff --git a/master/buildbot/test/unit/schedulers/test_forcesched.py b/master/buildbot/test/unit/schedulers/test_forcesched.py
--- a/master/buildbot/test/unit/schedulers/test_forcesched.py
+++ b/master/buildbot/test/unit/schedulers/test_forcesched.py
@@ -318,7 +318,7 @@ def test_codebase_with_patch(self):
                                 foo_branch='a', foo_revision='c', foo_repository='d',
                                 foo_project='p',
                                 bar_branch='a2', bar_revision='c2', bar_repository='d2',
-                                bar_project='p2', bar_patch_body="xxx")
+                                bar_project='p2', bar_patch_body=b"xxx")
 
         bsid, brids = res
         expProperties = {
@@ -335,7 +335,7 @@ def test_codebase_with_patch(self):
                 sourcestamps=[
                     {'branch': 'a2', 'project': 'p2', 'repository': 'd2',
                         'revision': 'c2', 'codebase': 'bar',
-                        'patch_body': 'xxx', 'patch_author': '', 'patch_subdir': '.',
+                        'patch_body': b'xxx', 'patch_author': '', 'patch_subdir': '.',
                         'patch_comment': '', 'patch_level': 1},
                     {'branch': 'a', 'project': 'p', 'repository': 'd',
                         'revision': 'c', 'codebase': 'foo'},
diff --git a/master/buildbot/test/unit/schedulers/test_trysched.py b/master/buildbot/test/unit/schedulers/test_trysched.py
--- a/master/buildbot/test/unit/schedulers/test_trysched.py
+++ b/master/buildbot/test/unit/schedulers/test_trysched.py
@@ -263,7 +263,7 @@ def test_parseJob_v1(self):
             'branch': 'trunk',
             'builderNames': ['buildera', 'builderc'],
             'jobid': 'extid',
-            'patch_body': 'this is my diff, -- ++, etc.',
+            'patch_body': b'this is my diff, -- ++, etc.',
             'patch_level': 1,
             'project': '',
             'who': '',
@@ -316,7 +316,7 @@ def test_parseJob_v2(self):
             'branch': 'trunk',
             'builderNames': ['buildera', 'builderc'],
             'jobid': 'extid',
-            'patch_body': 'this is my diff, -- ++, etc.',
+            'patch_body': b'this is my diff, -- ++, etc.',
             'patch_level': 1,
             'project': 'proj',
             'who': '',
@@ -372,7 +372,7 @@ def test_parseJob_v3(self):
             'branch': 'trunk',
             'builderNames': ['buildera', 'builderc'],
             'jobid': 'extid',
-            'patch_body': 'this is my diff, -- ++, etc.',
+            'patch_body': b'this is my diff, -- ++, etc.',
             'patch_level': 1,
             'project': 'proj',
             'who': 'who',
@@ -428,7 +428,7 @@ def test_parseJob_v4(self):
             'branch': 'trunk',
             'builderNames': ['buildera', 'builderc'],
             'jobid': 'extid',
-            'patch_body': 'this is my diff, -- ++, etc.',
+            'patch_body': b'this is my diff, -- ++, etc.',
             'patch_level': 1,
             'project': 'proj',
             'who': 'who',
@@ -488,7 +488,7 @@ def test_parseJob_v5(self):
             'branch': 'trunk',
             'builderNames': ['buildera', 'builderc'],
             'jobid': 'extid',
-            'patch_body': 'this is my diff, -- ++, etc.',
+            'patch_body': b'this is my diff, -- ++, etc.',
             'patch_level': 1,
             'project': 'proj',
             'who': 'who',
@@ -517,7 +517,7 @@ def test_parseJob_v5_no_builders(self):
             '5',
             json.dumps({
                 'jobid': 'extid', 'branch': 'trunk', 'baserev': '1234',
-                'patch_level': '1', 'diff': 'this is my diff, -- ++, etc.',
+                'patch_level': '1', 'patch_body': 'this is my diff, -- ++, etc.',
                 'repository': 'repo', 'project': 'proj', 'who': 'who',
                 'comment': 'comment', 'builderNames': [],
                 'properties': {'foo': 'bar'},
@@ -532,7 +532,7 @@ def test_parseJob_v5_no_properties(self):
             '5',
             json.dumps({
                 'jobid': 'extid', 'branch': 'trunk', 'baserev': '1234',
-                'patch_level': '1', 'diff': 'this is my diff, -- ++, etc.',
+                'patch_level': '1', 'patch_body': 'this is my diff, -- ++, etc.',
                 'repository': 'repo', 'project': 'proj', 'who': 'who',
                 'comment': 'comment', 'builderNames': ['buildera', 'builderb'],
                 'properties': {},
@@ -567,7 +567,7 @@ def parseJob_(f):
     def makeSampleParsedJob(self, **overrides):
         pj = dict(baserev='1234', branch='trunk',
                   builderNames=['buildera', 'builderb'],
-                  jobid='extid', patch_body='this is my diff, -- ++, etc.',
+                  jobid='extid', patch_body=b'this is my diff, -- ++, etc.',
                   patch_level=1, project='proj', repository='repo', who='who',
                   comment='comment', properties={})
         pj.update(overrides)
@@ -588,7 +588,7 @@ def test_handleJobFile(self):
                         branch='trunk',
                         codebase='',
                         patch_author='who',
-                        patch_body='this is my diff, -- ++, etc.',
+                        patch_body=b'this is my diff, -- ++, etc.',
                         patch_comment='comment',
                         patch_level=1,
                         patch_subdir='',
@@ -635,7 +635,7 @@ def test_handleJobFile_subset_builders(self):
                         branch='trunk',
                         codebase='',
                         patch_author='who',
-                        patch_body='this is my diff, -- ++, etc.',
+                        patch_body=b'this is my diff, -- ++, etc.',
                         patch_comment='comment',
                         patch_level=1,
                         patch_subdir='',
@@ -661,7 +661,7 @@ def test_handleJobFile_with_try_properties(self):
                         branch='trunk',
                         codebase='',
                         patch_author='who',
-                        patch_body='this is my diff, -- ++, etc.',
+                        patch_body=b'this is my diff, -- ++, etc.',
                         patch_comment='comment',
                         patch_level=1,
                         patch_subdir='',
@@ -720,6 +720,7 @@ def test_perspective_try(self):
             'default', 'abcdef', (1, '-- ++'), 'repo', 'proj', ['a'],
             properties={'pr': 'op'})
 
+        self.maxDiff = None
         self.assertEqual(self.addBuildsetCalls, [
             ('addBuildsetForSourceStamps', dict(
                 builderNames=['a'],
@@ -731,7 +732,7 @@ def test_perspective_try(self):
                         branch='default',
                         codebase='',
                         patch_author='',
-                        patch_body='-- ++',
+                        patch_body=b'-- ++',
                         patch_comment='',
                         patch_level=1,
                         patch_subdir='',
@@ -741,6 +742,35 @@ def test_perspective_try(self):
                 ])),
         ])
 
+    @defer.inlineCallbacks
+    def test_perspective_try_bytes(self):
+        yield self.call_perspective_try(
+            'default', 'abcdef', (1, b'-- ++\xf8'), 'repo', 'proj', ['a'],
+            properties={'pr': 'op'})
+
+        self.assertEqual(self.addBuildsetCalls, [
+            ('addBuildsetForSourceStamps', {
+                'builderNames': ['a'],
+                'external_idstring': None,
+                'properties': {'pr': ('op', 'try build')},
+                'reason': "'try' job",
+                'sourcestamps': [
+                    {
+                        'branch': 'default',
+                        'codebase': '',
+                        'patch_author': '',
+                        'patch_body': b'-- ++\xf8',
+                        'patch_comment': '',
+                        'patch_level': 1,
+                        'patch_subdir': '',
+                        'project': 'proj',
+                        'repository': 'repo',
+                        'revision': 'abcdef',
+                    }
+                ]
+            }),
+        ])
+
     @defer.inlineCallbacks
     def test_perspective_try_who(self):
         yield self.call_perspective_try(
@@ -758,7 +788,7 @@ def test_perspective_try_who(self):
                         branch='default',
                         codebase='',
                         patch_author='who',
-                        patch_body='-- ++',
+                        patch_body=b'-- ++',
                         patch_comment='comment',
                         patch_level=1,
                         patch_subdir='',
diff --git a/master/buildbot/test/unit/steps/test_source_base_Source.py b/master/buildbot/test/unit/steps/test_source_base_Source.py
--- a/master/buildbot/test/unit/steps/test_source_base_Source.py
+++ b/master/buildbot/test/unit/steps/test_source_base_Source.py
@@ -81,6 +81,20 @@ def test_start_alwaysUseLatest_False(self):
 
         self.assertEqual(step.run_vc.mock.call_args, (('other-branch', 'revision', 'patch'), {}))
 
+    def test_start_alwaysUseLatest_False_binary_patch(self):
+        args = {
+            'branch': 'other-branch',
+            'revision': 'revision',
+        }
+        step = self.setupStep(Source(), args, patch=(1, b'patch\xf8'))
+        step.branch = 'branch'
+        step.run_vc = self.setup_deferred_mock()
+
+        step.startStep(mock.Mock())
+
+        self.assertEqual(step.run_vc.mock.call_args,
+                         (('other-branch', 'revision', (1, b'patch\xf8')), {}))
+
     def test_start_alwaysUseLatest_False_no_branch(self):
         step = self.setupStep(Source())
         step.branch = 'branch'
diff --git a/master/buildbot/test/unit/test_clients_tryclient.py b/master/buildbot/test/unit/test_clients_tryclient.py
--- a/master/buildbot/test/unit/test_clients_tryclient.py
+++ b/master/buildbot/test/unit/test_clients_tryclient.py
@@ -14,12 +14,14 @@
 # Copyright Buildbot Team Members
 
 
+import base64
 import json
 import sys
 
 from twisted.trial import unittest
 
 from buildbot.clients import tryclient
+from buildbot.util import bytes2unicode
 
 
 class createJobfile(unittest.TestCase):
@@ -27,94 +29,41 @@ class createJobfile(unittest.TestCase):
     def makeNetstring(self, *strings):
         return ''.join(['{}:{},'.format(len(s), s) for s in strings])
 
-    # version 1 is deprecated and not produced by the try client
+    # versions 1-4 are deprecated and not produced by the try client
 
-    def test_createJobfile_v2_one_builder(self):
-        jobid = '123-456'
-        branch = 'branch'
-        baserev = 'baserev'
-        patch_level = 0
-        patch_body = 'diff...'
-        repository = 'repo'
-        project = 'proj'
-        who = None
-        comment = None
-        builderNames = ['runtests']
-        properties = {}
-        job = tryclient.createJobfile(
-            jobid, branch, baserev, patch_level, patch_body, repository,
-            project, who, comment, builderNames, properties)
-        jobstr = self.makeNetstring(
-            '2', jobid, branch, baserev, str(patch_level), patch_body,
-            repository, project, builderNames[0])
-        self.assertEqual(job, jobstr)
-
-    def test_createJobfile_v2_two_builders(self):
-        jobid = '123-456'
-        branch = 'branch'
-        baserev = 'baserev'
-        patch_level = 0
-        patch_body = 'diff...'
-        repository = 'repo'
-        project = 'proj'
-        who = None
-        comment = None
-        builderNames = ['runtests', 'moretests']
-        properties = {}
-        job = tryclient.createJobfile(
-            jobid, branch, baserev, patch_level, patch_body, repository,
-            project, who, comment, builderNames, properties)
-        jobstr = self.makeNetstring(
-            '2', jobid, branch, baserev, str(patch_level), patch_body,
-            repository, project, builderNames[0], builderNames[1])
-        self.assertEqual(job, jobstr)
-
-    def test_createJobfile_v3(self):
-        jobid = '123-456'
-        branch = 'branch'
-        baserev = 'baserev'
-        patch_level = 0
-        patch_body = 'diff...'
-        repository = 'repo'
-        project = 'proj'
-        who = 'someuser'
-        comment = None
-        builderNames = ['runtests']
-        properties = {}
-        job = tryclient.createJobfile(
-            jobid, branch, baserev, patch_level, patch_body, repository,
-            project, who, comment, builderNames, properties)
-        jobstr = self.makeNetstring(
-            '3', jobid, branch, baserev, str(patch_level), patch_body,
-            repository, project, who, builderNames[0])
-        self.assertEqual(job, jobstr)
-
-    def test_createJobfile_v4(self):
+    def test_createJobfile_v5(self):
         jobid = '123-456'
         branch = 'branch'
         baserev = 'baserev'
         patch_level = 0
-        patch_body = 'diff...'
+        patch_body = b'diff...'
         repository = 'repo'
         project = 'proj'
         who = 'someuser'
         comment = 'insightful comment'
         builderNames = ['runtests']
-        properties = {}
+        properties = {'foo': 'bar'}
         job = tryclient.createJobfile(
             jobid, branch, baserev, patch_level, patch_body, repository,
             project, who, comment, builderNames, properties)
         jobstr = self.makeNetstring(
-            '4', jobid, branch, baserev, str(patch_level), patch_body,
-            repository, project, who, comment, builderNames[0])
+            '5',
+            json.dumps({
+                'jobid': jobid, 'branch': branch, 'baserev': baserev,
+                'patch_level': patch_level,
+                'repository': repository, 'project': project, 'who': who,
+                'comment': comment, 'builderNames': builderNames,
+                'properties': properties,
+                'patch_body': bytes2unicode(patch_body),
+            }))
         self.assertEqual(job, jobstr)
 
-    def test_createJobfile_v5(self):
+    def test_createJobfile_v6(self):
         jobid = '123-456'
         branch = 'branch'
         baserev = 'baserev'
         patch_level = 0
-        patch_body = 'diff...'
+        patch_body = b'diff...\xff'
         repository = 'repo'
         project = 'proj'
         who = 'someuser'
@@ -125,13 +74,14 @@ def test_createJobfile_v5(self):
             jobid, branch, baserev, patch_level, patch_body, repository,
             project, who, comment, builderNames, properties)
         jobstr = self.makeNetstring(
-            '5',
+            '6',
             json.dumps({
                 'jobid': jobid, 'branch': branch, 'baserev': baserev,
-                'patch_level': patch_level, 'patch_body': patch_body,
+                'patch_level': patch_level,
                 'repository': repository, 'project': project, 'who': who,
                 'comment': comment, 'builderNames': builderNames,
                 'properties': properties,
+                'patch_body_base64': bytes2unicode(base64.b64encode(patch_body)),
             }))
         self.assertEqual(job, jobstr)
 
@@ -141,7 +91,7 @@ def test_SourceStampExtractor_readPatch(self):
             sse.readPatch(diff, patchlevel)
             self.assertEqual(sse.patch, (patchlevel, None))
         sse.readPatch(b"diff schmiff blah blah blah", 23)
-        self.assertEqual(sse.patch, (23, "diff schmiff blah blah blah"))
+        self.assertEqual(sse.patch, (23, b"diff schmiff blah blah blah"))
 
     def test_GitExtractor_fixBranch(self):
         sse = tryclient.GitExtractor(None, "origin/master", None)