Skip to content

Commit 657fd66

Browse files
committed
[py3-compat] Added py3 compatibility 🎉
Hybrid py2/py3 on a single codebase is achieved by using the `six` library here and there (mostly for imports) and a few `if six.PY2` conditionals. CI will test all currently supported versions of Python (that means 3.8 -> 3.12), plus 2.7 and 3.7 (supported in Ubuntu 20.04)
1 parent 6dc5a34 commit 657fd66

File tree

5 files changed

+72
-52
lines changed

5 files changed

+72
-52
lines changed

.github/workflows/runtests.yml

+12-11
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ jobs:
1414
- run: git config --global user.email runtest@localhost
1515
- run: nox --non-interactive --error-on-missing-interpreter --session runtests -- --git-default-branch=master
1616

17-
# runtests-py3:
18-
# runs-on: ubuntu-latest
19-
# steps:
20-
# - uses: wntrblm/[email protected]
21-
# with:
22-
# python-versions: "3.7"
23-
# - uses: actions/checkout@v4
24-
# - run: git config --global user.name runtest
25-
# - run: git config --global user.email runtest@localhost
26-
# - run: git config --global init.defaultBranch main
27-
# - run: nox --non-interactive --error-on-missing-interpreter --session runtests
17+
runtests-py3:
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- uses: wntrblm/[email protected]
22+
with:
23+
python-versions: "3.7, 3.8, 3.9, 3.10, 3.11, 3.12" # duplicated in noxfile.py
24+
- uses: actions/checkout@v4
25+
- run: git config --global user.name runtest
26+
- run: git config --global user.email runtest@localhost
27+
- run: git config --global init.defaultBranch main
28+
- run: nox --non-interactive --error-on-missing-interpreter --session runtests

noxfile.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
if sys.version_info.major == 2:
66
TRAC_VERSIONS = ["1.4.4", "1.2.6"]
7+
PYTHON_VERSIONS = ["2.7"]
78
else:
89
TRAC_VERSIONS = ["1.6"]
10+
PYTHON_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] # duplicated in runtests.yml
911

1012

11-
@nox.session
13+
@nox.session(python=PYTHON_VERSIONS)
1214
@nox.parametrize("trac", TRAC_VERSIONS)
1315
def runtests(session, trac):
1416
session.install("-r", "requirements_test.txt")
1517
session.install("Trac==%s" % trac)
18+
session.run("python", "--version")
1619
session.run("python", "runtests.py", *session.posargs)

runtests.py

+44-35
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from __future__ import print_function
1212

1313
import argparse
14-
import BaseHTTPServer
15-
import ConfigParser
1614
import json
1715
import os
1816
import random
@@ -26,7 +24,11 @@
2624
import time
2725
import traceback
2826
import unittest
29-
import urlparse
27+
28+
29+
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
30+
from six.moves.configparser import ConfigParser
31+
from six.moves.urllib.parse import parse_qs, urlparse
3032

3133
from lxml import html
3234

@@ -35,6 +37,7 @@
3537
from trac.util.translation import _
3638

3739
import requests
40+
import six
3841

3942

4043
GIT = 'test-git-foo'
@@ -83,6 +86,9 @@ def git_check_output(*args, **kwargs):
8386
as a string.
8487
"""
8588
repo = kwargs.pop('repo', None)
89+
kwargs.setdefault('text', True)
90+
if six.PY2:
91+
del kwargs['text']
8692

8793
if repo is None:
8894
cmdargs = ["git"] + list(args)
@@ -136,9 +142,12 @@ def createTracEnvironment(cls, **kwargs):
136142
subprocess.check_output([TRAC_ADMIN_BIN, d(ENV), 'permission',
137143
'add', 'anonymous', 'TRAC_ADMIN'])
138144

139-
conf = ConfigParser.ConfigParser()
140-
with open(d(CONF), 'rb') as fp:
141-
conf.readfp(fp)
145+
conf = ConfigParser()
146+
with open(d(CONF), 'r') as fp:
147+
if six.PY2:
148+
conf.readfp(fp)
149+
else:
150+
conf.read_file(fp)
142151

143152
conf.add_section('components')
144153
conf.set('components', 'trac.versioncontrol.web_ui.browser.BrowserModule', 'disabled')
@@ -210,7 +219,7 @@ def createTracEnvironment(cls, **kwargs):
210219
conf.set('trac', 'permission_policies',
211220
'GitHubPolicy, %s' % old_permission_policies)
212221

213-
with open(d(CONF), 'wb') as fp:
222+
with open(d(CONF), 'w') as fp:
214223
conf.write(fp)
215224

216225
with open(d(HTDIGEST), 'w') as fp:
@@ -269,7 +278,7 @@ def makeGitCommit(repo, path, content, message='edit', branch=None):
269278

270279
if branch != GIT_DEFAULT_BRANCH:
271280
git_check_output('checkout', branch, repo=repo)
272-
with open(d(repo, path), 'wb') as fp:
281+
with open(d(repo, path), 'w') as fp:
273282
fp.write(content)
274283
git_check_output('add', path, repo=repo)
275284
git_check_output('commit', '-m', message, repo=repo)
@@ -406,11 +415,11 @@ def testLogin(self):
406415
response = requests.get(u('github/login'), allow_redirects=False)
407416
self.assertEqual(response.status_code, 302)
408417

409-
redirect_url = urlparse.urlparse(response.headers['Location'])
418+
redirect_url = urlparse(response.headers['Location'])
410419
self.assertEqual(redirect_url.scheme, 'https')
411420
self.assertEqual(redirect_url.netloc, 'github.com')
412421
self.assertEqual(redirect_url.path, '/login/oauth/authorize')
413-
params = urlparse.parse_qs(redirect_url.query, keep_blank_values=True)
422+
params = parse_qs(redirect_url.query, keep_blank_values=True)
414423
state = params['state'][0] # this is a random value
415424
self.assertEqual(params, {
416425
'client_id': ['01234567890123456789'],
@@ -490,7 +499,7 @@ def setUpClass(cls):
490499
cls.trac_env_broken = trac_env_broken
491500
cls.trac_env_broken_api = trac_env_broken_api
492501

493-
with open(d(SECRET), 'wb') as fp:
502+
with open(d(SECRET), 'w') as fp:
494503
fp.write('98765432109876543210')
495504

496505

@@ -505,11 +514,11 @@ def testLoginWithReqEmail(self):
505514
response = requests.get(u('github/login'), allow_redirects=False)
506515
self.assertEqual(response.status_code, 302)
507516

508-
redirect_url = urlparse.urlparse(response.headers['Location'])
517+
redirect_url = urlparse(response.headers['Location'])
509518
self.assertEqual(redirect_url.scheme, 'https')
510519
self.assertEqual(redirect_url.netloc, 'github.com')
511520
self.assertEqual(redirect_url.path, '/login/oauth/authorize')
512-
params = urlparse.parse_qs(redirect_url.query, keep_blank_values=True)
521+
params = parse_qs(redirect_url.query, keep_blank_values=True)
513522
state = params['state'][0] # this is a random value
514523
self.assertEqual(params, {
515524
'client_id': ['01234567890123456789'],
@@ -527,11 +536,11 @@ def loginAndVerifyClientId(self, expected_client_id):
527536
response = requests.get(u('github/login'), allow_redirects=False)
528537
self.assertEqual(response.status_code, 302)
529538

530-
redirect_url = urlparse.urlparse(response.headers['Location'])
539+
redirect_url = urlparse(response.headers['Location'])
531540
self.assertEqual(redirect_url.scheme, 'https')
532541
self.assertEqual(redirect_url.netloc, 'github.com')
533542
self.assertEqual(redirect_url.path, '/login/oauth/authorize')
534-
params = urlparse.parse_qs(redirect_url.query, keep_blank_values=True)
543+
params = parse_qs(redirect_url.query, keep_blank_values=True)
535544
state = params['state'][0] # this is a random value
536545
self.assertEqual(params, {
537546
'client_id': [expected_client_id],
@@ -636,8 +645,8 @@ def attemptValidOauth(self, testenv, callback, **kwargs):
636645
self.assertEqual(response.status_code, 302)
637646

638647
# Extract the state from the redirect
639-
redirect_url = urlparse.urlparse(response.headers['Location'])
640-
params = urlparse.parse_qs(redirect_url.query, keep_blank_values=True)
648+
redirect_url = urlparse(response.headers['Location'])
649+
params = parse_qs(redirect_url.query, keep_blank_values=True)
641650
state = params['state'][0] # this is a random value
642651
response = session.get(
643652
u('github/oauth'),
@@ -938,7 +947,7 @@ def testAlternativeRepository(self):
938947
def testCommit(self):
939948
self.makeGitCommit(GIT, 'foo', 'foo content\n')
940949
output = self.openGitHubHook()
941-
self.assertRegexpMatches(output, r"Running hook on \(default\)\n"
950+
six.assertRegex(self, output, r"Running hook on \(default\)\n"
942951
r"\* Updating clone\n"
943952
r"\* Synchronizing with clone\n"
944953
r"\* Adding commit [0-9a-f]{40}\n")
@@ -947,7 +956,7 @@ def testMultipleCommits(self):
947956
self.makeGitCommit(GIT, 'bar', 'bar content\n')
948957
self.makeGitCommit(GIT, 'bar', 'more bar content\n')
949958
output = self.openGitHubHook(2)
950-
self.assertRegexpMatches(output, r"Running hook on \(default\)\n"
959+
six.assertRegex(self, output, r"Running hook on \(default\)\n"
951960
r"\* Updating clone\n"
952961
r"\* Synchronizing with clone\n"
953962
r"\* Adding commits [0-9a-f]{40}, [0-9a-f]{40}\n")
@@ -958,7 +967,7 @@ def testCommitOnBranch(self):
958967
self.makeGitBranch(ALTGIT, 'unstable/1.0')
959968
self.makeGitCommit(ALTGIT, 'unstable', 'unstable branch\n', branch='unstable/1.0')
960969
output = self.openGitHubHook(2, 'alt')
961-
self.assertRegexpMatches(output, r"Running hook on alt\n"
970+
six.assertRegex(self, output, r"Running hook on alt\n"
962971
r"\* Updating clone\n"
963972
r"\* Synchronizing with clone\n"
964973
r"\* Adding commit [0-9a-f]{40}\n"
@@ -969,7 +978,7 @@ def testUnknownCommit(self):
969978
random_id = ''.join(random.choice('0123456789abcdef') for _ in range(40))
970979
payload = {'commits': [{'id': random_id, 'message': '', 'distinct': True}]}
971980
response = requests.post(u('github'), json=payload, headers=HEADERS)
972-
self.assertRegexpMatches(response.text, r"Running hook on \(default\)\n"
981+
six.assertRegex(self, response.text, r"Running hook on \(default\)\n"
973982
r"\* Updating clone\n"
974983
r"\* Synchronizing with clone\n"
975984
r"\* Unknown commit [0-9a-f]{40}\n")
@@ -1095,13 +1104,13 @@ class GitHubPostCommitHookWithUpdateHookTests(TracGitHubTests):
10951104

10961105
@classmethod
10971106
def createUpdateHook(cls):
1098-
with open(d(UPDATEHOOK), 'wb') as fp:
1107+
with open(d(UPDATEHOOK), 'w') as fp:
10991108
# simple shell script to echo back all input
11001109
fp.write("""#!/bin/sh\nexec cat""")
11011110
os.fchmod(fp.fileno(), 0o755)
11021111

11031112
def createFailingUpdateHook(cls):
1104-
with open(d(UPDATEHOOK), 'wb') as fp:
1113+
with open(d(UPDATEHOOK), 'w') as fp:
11051114
fp.write("""#!/bin/sh\nexit 1""")
11061115
os.fchmod(fp.fileno(), 0o755)
11071116

@@ -1128,7 +1137,7 @@ def testUpdateHook(self):
11281137
self.makeGitCommit(GIT, 'foo', 'foo content\n')
11291138
payload = self.makeGitHubHookPayload()
11301139
output = self.openGitHubHook(payload=payload)
1131-
self.assertRegexpMatches(output, r"Running hook on \(default\)\n"
1140+
six.assertRegex(self, output, r"Running hook on \(default\)\n"
11321141
r"\* Updating clone\n"
11331142
r"\* Synchronizing with clone\n"
11341143
r"\* Adding commit [0-9a-f]{40}\n"
@@ -1139,14 +1148,14 @@ def testUpdateHookExecFailure(self):
11391148
os.chmod(d(UPDATEHOOK), 0o644)
11401149
self.makeGitCommit(GIT, 'bar', 'bar content\n')
11411150
payload = self.makeGitHubHookPayload()
1142-
with self.assertRaisesRegexp(requests.HTTPError, r'^500 Server Error: Internal Server Error'):
1151+
with six.assertRaisesRegex(self, requests.HTTPError, r'^500 Server Error: Internal Server Error'):
11431152
output = self.openGitHubHook(payload=payload)
11441153

11451154
def testUpdateHookFailure(self):
11461155
self.createFailingUpdateHook()
11471156
self.makeGitCommit(GIT, 'baz', 'baz content\n')
11481157
payload = self.makeGitHubHookPayload()
1149-
with self.assertRaisesRegexp(requests.HTTPError, r'^500 Server Error: Internal Server Error'):
1158+
with six.assertRaisesRegex(self, requests.HTTPError, r'^500 Server Error: Internal Server Error'):
11501159
output = self.openGitHubHook(payload=payload)
11511160

11521161

@@ -1160,7 +1169,7 @@ class GitHubPostCommitHookWithCacheTests(GitHubPostCommitHookTests):
11601169
cached_git = True
11611170

11621171

1163-
class GitHubAPIMock(BaseHTTPServer.BaseHTTPRequestHandler):
1172+
class GitHubAPIMock(BaseHTTPRequestHandler):
11641173
def log_message(self, format, *args):
11651174
# Visibly differentiate GitHub API mock logging from tracd logs
11661175
sys.stderr.write("%s [%s] %s\n" %
@@ -1221,7 +1230,7 @@ def do_GET(self):
12211230
self.send_header("Content-Type", contenttype)
12221231
self.end_headers()
12231232

1224-
self.wfile.write(json.dumps(answer))
1233+
self.wfile.write(json.dumps(answer, ensure_ascii=True).encode('ascii'))
12251234

12261235
def do_POST(self):
12271236
md = self.server.mockdata
@@ -1239,9 +1248,9 @@ def do_POST(self):
12391248
chunk = self.rfile.read(chunk_size)
12401249
if not chunk:
12411250
break
1242-
L.append(chunk)
1251+
L.append(chunk.decode('ascii'))
12431252
size_remaining -= len(L[-1])
1244-
args = urlparse.parse_qs(''.join(L))
1253+
args = parse_qs(''.join(L))
12451254

12461255
retcode = 404
12471256
answer = {}
@@ -1255,7 +1264,7 @@ def do_POST(self):
12551264
self.send_response(retcode)
12561265
self.send_header("Content-Type", contenttype)
12571266
self.end_headers()
1258-
self.wfile.write(json.dumps(answer))
1267+
self.wfile.write(json.dumps(answer, ensure_ascii=True).encode('ascii'))
12591268

12601269

12611270
class TracContext(object):
@@ -1836,7 +1845,7 @@ def test_014_hook_membership_event_add_team(self):
18361845
self.assertGreater(len(data), 0, "No groups returned after update")
18371846
self.assertIn(users[0]["login"], data,
18381847
"User %s expected after update, but not present" % users[0]["login"])
1839-
self.assertItemsEqual(
1848+
six.assertCountEqual(self,
18401849
data[users[0]["login"]],
18411850
(u"github-%s-justice-league" % self.organization, u"github-%s" % self.organization),
18421851
"User %s does not have expected groups after update" % users[0]["login"])
@@ -1901,7 +1910,7 @@ def test_015_hook_membership_event_add_member(self):
19011910
self.assertGreater(len(data), 0, "No groups returned after update")
19021911
self.assertIn(users[1]["login"], data,
19031912
"User %s expected after update, but not present" % users[1]["login"])
1904-
self.assertItemsEqual(
1913+
six.assertCountEqual(self,
19051914
data[users[1]["login"]],
19061915
(u"github-%s-justice-league" % self.organization, u"github-%s" % self.organization),
19071916
"User %s does not have expected groups after update" % users[1]["login"])
@@ -2113,7 +2122,7 @@ def updateMockData(md, retcode=None, contenttype=None, answers=None,
21132122
JSON-encoded and returned for requests to the paths.
21142123
:param postcallback: A callback function called for the next POST requests.
21152124
Arguments are the requested path and a dict of POST
2116-
data as returned by `urlparse.parse_qs()`. The
2125+
data as returned by `parse_qs()`. The
21172126
callback should return a tuple `(retcode, answer)`
21182127
where `retcode` is the HTTP return code and `answer`
21192128
will be JSON-encoded and sent to the client. Note that
@@ -2148,7 +2157,7 @@ def apiMockServer(port, mockdata):
21482157
be JSON-encoded and returned. Use `updateMockData()` to
21492158
update the contents of the mockdata dict.
21502159
"""
2151-
httpd = BaseHTTPServer.HTTPServer(('127.0.0.1', port), GitHubAPIMock)
2160+
httpd = HTTPServer(('127.0.0.1', port), GitHubAPIMock)
21522161
# Make mockdata available to server
21532162
httpd.mockdata = mockdata
21542163
httpd.serve_forever()

setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
namespace_packages=['tracext'],
1919
platforms='all',
2020
license='BSD',
21+
install_requires=[
22+
'six==1.16.0',
23+
],
2124
extras_require={'oauth': ['requests_oauthlib >= 0.5']},
2225
entry_points={'trac.plugins': [
2326
'github.browser = tracext.github:GitHubBrowser',

tracext/github/__init__.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from trac.web.auth import LoginModule
3030
from trac.web.chrome import add_warning
3131

32+
import six
33+
3234
def _config_secret(value):
3335
if re.match(r'[A-Z_]+', value):
3436
return os.environ.get(value, '')
@@ -330,9 +332,11 @@ def __init__(self, api, env, fullname):
330332
self.api = api
331333
self.env = env
332334
self.name = fullname
333-
# _fullname needs to be a string, not a unicode string, otherwise the
334-
# cache object won't convert it into a hash.
335-
self._fullname = fullname.encode('utf-8')
335+
self._fullname = fullname
336+
if six.PY2:
337+
# Trac's @cached for py2 does an isinstance(..., str) so we need a native
338+
# string type
339+
self._fullname = fullname.encode('utf-8')
336340
# next try: immediately
337341
self._next_update = datetime.now() - timedelta(seconds=10)
338342
self._cached_result = self._apiresult_error()
@@ -656,10 +660,10 @@ def github_api(self, url, *args):
656660
additional positional arguments.
657661
"""
658662
import requests
659-
import urllib
663+
from six.moves.urllib.parse import quote
660664

661665
github_api_url = os.environ.get("TRAC_GITHUB_API_URL", "https://api.github.com/")
662-
formatted_url = github_api_url + url.format(*(urllib.quote(str(x)) for x in args))
666+
formatted_url = github_api_url + url.format(*(quote(str(x)) for x in args))
663667
access_token = _config_secret(self.access_token)
664668
self.log.debug("Hitting GitHub API endpoint %s with user %s", formatted_url, self.username) # pylint: disable=no-member
665669
results = []

0 commit comments

Comments
 (0)