Repository: ome/omero-web (language: python)

Issue:
Remove python-future compatibility code
Similar to https://github.com/ome/omero-py/pull/390, we should review the OMERO.web source code to update all paths with either Python 2 or Python 2/3 logic to pure Python 3+.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index b15ff11d2e..f7dea26746 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -15,6 +15,7 @@ jobs:
           - '3.9'
           - '3.10'
           - '3.11'
+          - '3.12'
     runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@v3
@@ -22,7 +23,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install dependencies
-        run: python -mpip install --upgrade wheel pytest tox
+        run: python -mpip install --upgrade wheel pytest tox setuptools
       - name: Get tox target
         id: toxtarget
         run: |
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 30b1ea4949..0000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,30 +0,0 @@
-FROM centos:centos7
-RUN localedef -i en_US -f UTF-8 en_US.UTF-8
-ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
-RUN yum install -y centos-release-scl \
- && yum install -y rh-python36 \
- && yum install -y python-virtualenv \
- && yum install -y openssl-devel git \
- && virtualenv /py2 && /py2/bin/pip install -U pip tox future wheel
-ENV PATH=/opt/rh/rh-python36/root/bin/:$PATH
-RUN python -m venv /py3 && /py3/bin/pip install -U pip tox future wheel
-
-ENV VIRTUAL_ENV=/py3
-ENV PATH="$VIRTUAL_ENV/bin:$PATH"
-
-RUN useradd -ms /bin/bash tox
-USER tox
-
-# Optimize for fixing tests
-COPY --chown=tox:tox *.py /src/
-COPY --chown=tox:tox README.rst /src
-COPY --chown=tox:tox MANIFEST.in /src
-COPY --chown=tox:tox omero /src/omero
-COPY --chown=tox:tox omeroweb /src/omeroweb
-WORKDIR /src
-
-# Copy test-related files and run
-COPY --chown=tox:tox *.ini /src/
-COPY --chown=tox:tox test /src/test
-ENV PIP_CACHE_DIR=/tmp/pip-cache
-ENTRYPOINT ["/py3/bin/tox"]
diff --git a/omero/plugins/web.py b/omero/plugins/web.py
index 0d96b36976..9653eb9a8b 100644
--- a/omero/plugins/web.py
+++ b/omero/plugins/web.py
@@ -9,7 +9,6 @@
 """
 
 import traceback
-from future.utils import bytes_to_native_str
 from datetime import datetime
 from omero.cli import DiagnosticsControl
 from omero.cli import CLI
@@ -21,11 +20,7 @@
 from functools import wraps
 from omero_ext.argparse import SUPPRESS
 
-try:
-    from omero_ext.path import path
-except ImportError:
-    # Python 2
-    from path import path
+from omero_ext.path import path
 from pkg_resources import resource_string
 
 from omero.install.windows_warning import windows_warning, WINDOWS_WARNING
@@ -348,9 +343,7 @@ def config(self, args, settings):
             self.ctx.die(679, "Web template configuration requires" "wsgi or wsgi-tcp.")
 
         template_file = "%s.conf.template" % server
-        c = bytes_to_native_str(
-            resource_string("omeroweb", "templates/" + template_file)
-        )
+        c = resource_string("omeroweb", "templates/" + template_file).decode("utf-8")
         self.ctx.out(c % d)
 
     def syncmedia(self, args):
diff --git a/omeroweb/connector.py b/omeroweb/connector.py
index de6775e76a..b21071ec39 100644
--- a/omeroweb/connector.py
+++ b/omeroweb/connector.py
@@ -22,8 +22,6 @@
 import re
 import logging
 
-from future.utils import with_metaclass
-
 from omero import client_wrapper
 from omeroweb.version import omeroweb_version as omero_version
 
@@ -40,8 +38,7 @@ def __iter__(cls):
         return iter(cls._registry.values())
 
 
-# with_metaclass to support python2 and python3
-class ServerBase(with_metaclass(IterRegistry)):
+class ServerBase(metaclass=IterRegistry):
     _next_id = 1
 
     def __init__(self, host, port, server=None):
diff --git a/omeroweb/custom_forms.py b/omeroweb/custom_forms.py
index a9575c0977..f3dc0e098b 100644
--- a/omeroweb/custom_forms.py
+++ b/omeroweb/custom_forms.py
@@ -20,7 +20,6 @@
 #
 
 import warnings
-from past.builtins import basestring
 from django import forms
 from django.utils.encoding import smart_str
 
@@ -62,12 +61,10 @@ def full_clean(self):
                     initial = self.initial.get(name, field.initial)
                     value = field.clean(value, initial)
                 elif isinstance(field, CharField):
-                    if (
-                        value is not None
-                        and isinstance(value, basestring)
-                        and len(value) > 0
-                    ):
-                        value = str(smart_str(value))
+                    if value is not None and isinstance(value, str) and len(value) > 0:
+                        value = str(
+                            smart_str(value)
+                        )  # TODO: This is probably a noop now
                     else:
                         value = field.clean(value)
                 else:
diff --git a/omeroweb/feedback/sendfeedback.py b/omeroweb/feedback/sendfeedback.py
index 931cc256bf..0c3b9b659e 100644
--- a/omeroweb/feedback/sendfeedback.py
+++ b/omeroweb/feedback/sendfeedback.py
@@ -29,19 +29,9 @@
 import logging
 from urllib.parse import urlencode
 
-try:
-    # python2
-    from urllib2 import urlopen, Request, HTTPError, URLError
-except ImportError:
-    # python3
-    from urllib.request import urlopen, Request
-    from urllib.error import HTTPError, URLError
-try:
-    # python2
-    from urlparse import urljoin
-except ImportError:
-    # python3
-    from urllib.parse import urljoin
+from urllib.request import urlopen, Request
+from urllib.error import HTTPError, URLError
+from urllib.parse import urljoin
 
 from omeroweb.version import omeroweb_version as omero_version
 
diff --git a/omeroweb/manage.py b/omeroweb/manage.py
index f3edf35590..ec7f7c256c 100644
--- a/omeroweb/manage.py
+++ b/omeroweb/manage.py
@@ -38,7 +38,7 @@
     except ImportError:
         # The above import may fail for some other reason. Ensure that the
         # issue is really that Django is missing to avoid masking other
-        # exceptions on Python 2.
+        # exceptions.
         try:
             import django
         except ImportError:
diff --git a/omeroweb/settings.py b/omeroweb/settings.py
index 47a87caf1e..b48f33ccc0 100755
--- a/omeroweb/settings.py
+++ b/omeroweb/settings.py
@@ -38,7 +38,6 @@
 import json
 import random
 import string
-from builtins import str as text
 import portalocker
 
 from omero.util.concurrency import get_event
@@ -1259,18 +1258,6 @@ def check_worker_class(c):
     return str(c)
 
 
-def check_threading(t):
-    t = int(t)
-    if t > 1:
-        try:
-            import concurrent.futures  # NOQA
-        except ImportError:
-            raise ImportError(
-                "You are using sync workers with " "multiple threads. Install futures"
-            )
-    return t
-
-
 # DEVELOPMENT_SETTINGS_MAPPINGS - WARNING: For each setting developer MUST open
 # a ticket that needs to be resolved before a release either by moving the
 # setting to CUSTOM_SETTINGS_MAPPINGS or by removing the setting at all.
@@ -1298,7 +1285,7 @@ def check_threading(t):
     "omero.web.wsgi_threads": [
         "WSGI_THREADS",
         1,
-        check_threading,
+        int,
         (
             "(SYNC WORKERS only) The number of worker threads for handling "
             "requests. Check Gunicorn Documentation "
@@ -1770,8 +1757,8 @@ def report_settings(module):
 # Load server list and freeze
 def load_server_list():
     for s in SERVER_LIST:  # from CUSTOM_SETTINGS_MAPPINGS  # noqa
-        server = (len(s) > 2) and text(s[2]) or None
-        Server(host=text(s[0]), port=int(s[1]), server=server)
+        server = (len(s) > 2) and str(s[2]) or None
+        Server(host=str(s[0]), port=int(s[1]), server=server)
     Server.freeze()
 
 
diff --git a/omeroweb/utils.py b/omeroweb/utils.py
index a9bae69223..7145ffea33 100644
--- a/omeroweb/utils.py
+++ b/omeroweb/utils.py
@@ -26,7 +26,6 @@
 from django.utils.http import urlencode
 from django.urls import reverse
 from django.urls import NoReverseMatch
-from past.builtins import basestring
 
 
 logger = logging.getLogger(__name__)
@@ -95,7 +94,7 @@ def reverse_with_params(*args, **kwargs):
     except NoReverseMatch:
         return url
     if qs:
-        if not isinstance(qs, basestring):
+        if not isinstance(qs, str):
             qs = urlencode(qs)
         url += "?" + qs
     return url
diff --git a/omeroweb/webadmin/custom_forms.py b/omeroweb/webadmin/custom_forms.py
index 6b7485f2ce..4d817bb665 100644
--- a/omeroweb/webadmin/custom_forms.py
+++ b/omeroweb/webadmin/custom_forms.py
@@ -34,8 +34,6 @@
 from django.utils.encoding import smart_str
 from django.core.validators import validate_email, EMPTY_VALUES
 
-from past.builtins import long
-
 
 ##################################################################
 # Fields
@@ -101,7 +99,7 @@ def to_python(self, value):
             return None
         res = False
         for q in self.queryset:
-            if long(value) == q.id:
+            if int(value) == q.id:
                 res = True
         if not res:
             raise ValidationError(self.error_messages["invalid_choice"])
@@ -167,10 +165,10 @@ def to_python(self, value):
             exps = self.queryset
         for experimenter in exps:
             if hasattr(experimenter.id, "val"):
-                if long(value) == experimenter.id.val:
+                if int(value) == experimenter.id.val:
                     res = True
             else:
-                if long(value) == experimenter.id:
+                if int(value) == experimenter.id:
                     res = True
         if not res:
             raise ValidationError(self.error_messages["invalid_choice"])
@@ -221,17 +219,17 @@ def to_python(self, value):
         final_values = []
         for val in value:
             try:
-                long(val)
+                int(val)
             except Exception:
                 raise ValidationError(self.error_messages["invalid_choice"])
             else:
                 res = False
                 for q in self.queryset:
                     if hasattr(q.id, "val"):
-                        if long(val) == q.id.val:
+                        if int(val) == q.id.val:
                             res = True
                     else:
-                        if long(val) == q.id:
+                        if int(val) == q.id:
                             res = True
                 if not res:
                     raise ValidationError(self.error_messages["invalid_choice"])
@@ -354,9 +352,9 @@ def checkValue(q, value):
             if not hasattr(q, "id"):
                 return False
             if hasattr(q.id, "val"):
-                if long(value) == q.id.val:
+                if int(value) == q.id.val:
                     return True
-            if long(value) == q.id:
+            if int(value) == q.id:
                 return True
 
         for q in self.queryset:
@@ -421,17 +419,17 @@ def to_python(self, value):
         final_values = []
         for val in value:
             try:
-                long(val)
+                int(val)
             except Exception:
                 raise ValidationError(self.error_messages["invalid_choice"])
             else:
                 res = False
                 for q in self.queryset:
                     if hasattr(q.id, "val"):
-                        if long(val) == q.id.val:
+                        if int(val) == q.id.val:
                             res = True
                     else:
-                        if long(val) == q.id:
+                        if int(val) == q.id:
                             res = True
                 if not res:
                     raise ValidationError(self.error_messages["invalid_choice"])
diff --git a/omeroweb/webadmin/forms.py b/omeroweb/webadmin/forms.py
index abf75fa4f0..a017ac1924 100644
--- a/omeroweb/webadmin/forms.py
+++ b/omeroweb/webadmin/forms.py
@@ -23,10 +23,7 @@
 
 import logging
 
-try:
-    from collections import OrderedDict  # Python 2.7+ only
-except Exception:
-    pass
+from collections import OrderedDict
 
 from django import forms
 from django.forms.widgets import Textarea
diff --git a/omeroweb/webclient/controller/history.py b/omeroweb/webclient/controller/history.py
index bae3041fa3..436ed7b158 100755
--- a/omeroweb/webclient/controller/history.py
+++ b/omeroweb/webclient/controller/history.py
@@ -27,7 +27,6 @@
 import datetime
 import time
 
-from past.builtins import long
 from django.conf import settings
 
 from omeroweb.webclient.controller import BaseController
@@ -172,8 +171,8 @@ def calendar_items(self, month, monthrange):
             ("%i-%s-%i 23:59:59" % (self.year, mn, monthrange)), "%Y-%m-%d %H:%M:%S"
         )
 
-        start = long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
-        end = long(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond) * 1000
+        start = int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
+        end = int(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond) * 1000
         all_logs = self.conn.getEventsByPeriod(start, end, self.eid)
 
         items = dict()
@@ -218,8 +217,8 @@ def get_items(self, page=None):
             ("%i-%s-%s 23:59:59" % (self.year, mn, dy)), "%Y-%m-%d %H:%M:%S"
         )
 
-        start = long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
-        end = long(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond) * 1000
+        start = int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
+        end = int(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond) * 1000
 
         self.day_items = list()
         self.day_items_size = 0
diff --git a/omeroweb/webclient/controller/search.py b/omeroweb/webclient/controller/search.py
index b77d0f348e..844b8210af 100755
--- a/omeroweb/webclient/controller/search.py
+++ b/omeroweb/webclient/controller/search.py
@@ -23,7 +23,6 @@
 # Version: 1.0
 #
 
-from past.builtins import long
 import time
 import omero
 import logging
@@ -89,11 +88,11 @@ def search(
 
                     created = [
                         rtime(
-                            long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond)
+                            int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond)
                             * 1000
                         ),
                         rtime(
-                            long(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond)
+                            int(time.mktime(d2.timetuple()) + 1e-6 * d2.microsecond)
                             * 1000
                         ),
                     ]
diff --git a/omeroweb/webclient/controller/share.py b/omeroweb/webclient/controller/share.py
index 2085e43d94..15cf6a0fbc 100644
--- a/omeroweb/webclient/controller/share.py
+++ b/omeroweb/webclient/controller/share.py
@@ -25,7 +25,6 @@
 
 import datetime
 import time
-from past.builtins import long
 
 from omero.rtypes import rtime
 
@@ -99,7 +98,7 @@ def createShare(self, host, images, message, members, enable, expiration=None):
                 expiration + " 23:59:59", "%Y-%m-%d %H:%M:%S"
             )
             expiration_date = (
-                long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
+                int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
             )
         image_objects = list(self.conn.getObjects("Image", images))
         member_objects = list(self.conn.getObjects("Experimenter", members))
@@ -114,7 +113,7 @@ def createDiscussion(self, host, message, members, enable, expiration=None):
                 expiration + " 23:59:59", "%Y-%m-%d %H:%M:%S"
             )
             expiration_date = rtime(
-                long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
+                int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
             )
         member_objects = list(self.conn.getObjects("Experimenter", members))
         return self.conn.createShare(
@@ -128,7 +127,7 @@ def updateShareOrDiscussion(self, host, message, members, enable, expiration=Non
                 expiration + " 23:59:59", "%Y-%m-%d %H:%M:%S"
             )
             expiration_date = (
-                long(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
+                int(time.mktime(d1.timetuple()) + 1e-6 * d1.microsecond) * 1000
             )
 
         old_groups = [m._obj for m in self.conn.getAllMembers(self.share.id)]
diff --git a/omeroweb/webclient/tree.py b/omeroweb/webclient/tree.py
index 8f6827acd4..67e3443056 100644
--- a/omeroweb/webclient/tree.py
+++ b/omeroweb/webclient/tree.py
@@ -23,7 +23,6 @@
 import time
 import pytz
 import omero
-from builtins import bytes
 from collections import defaultdict
 
 from omero.rtypes import rlong, unwrap, wrap
diff --git a/omeroweb/webclient/views.py b/omeroweb/webclient/views.py
index 017ca7e328..0978f6d4e0 100755
--- a/omeroweb/webclient/views.py
+++ b/omeroweb/webclient/views.py
@@ -36,8 +36,6 @@
 import sys
 import warnings
 from collections import defaultdict
-from past.builtins import unicode
-from future.utils import bytes_to_native_str
 from django.utils.html import escape
 from django.utils.http import url_has_allowed_host_and_scheme
 
@@ -129,10 +127,6 @@
 
 from . import tree
 
-try:
-    import long
-except ImportError:
-    long = int
 
 logger = logging.getLogger(__name__)
 
@@ -154,7 +148,7 @@ def get_long_or_default(request, name, default):
     val = None
     val_raw = request.GET.get(name, default)
     if val_raw is not None:
-        val = long(val_raw)
+        val = int(val_raw)
     return val
 
 
@@ -292,7 +286,7 @@ def handle_not_logged_in(self, request, error=None, form=None):
         if form is None:
             server_id = request.GET.get("server", request.POST.get("server"))
             if server_id is not None:
-                initial = {"server": unicode(server_id)}
+                initial = {"server": str(server_id)}
                 form = LoginForm(initial=initial)
             else:
                 form = LoginForm()
@@ -506,7 +500,7 @@ def _load_template(request, menu, conn=None, url=None, **kwargs):
             # if we're not already showing 'All Members'...
             user_id = initially_open_owner
     try:
-        user_id = long(user_id)
+        user_id = int(user_id)
     except Exception:
         user_id = None
     # check if user_id is in a currnt group
@@ -554,8 +548,8 @@ def _load_template(request, menu, conn=None, url=None, **kwargs):
     }
     context["groups"] = groups
     context["myColleagues"] = myColleagues
-    context["active_group"] = conn.getObject("ExperimenterGroup", long(active_group))
-    context["active_user"] = conn.getObject("Experimenter", long(user_id))
+    context["active_group"] = conn.getObject("ExperimenterGroup", int(active_group))
+    context["active_user"] = conn.getObject("Experimenter", int(user_id))
     context["initially_select"] = show.initially_select
     context["initially_open"] = show.initially_open
     context["isLeader"] = conn.isLeader()
@@ -642,7 +636,7 @@ def api_group_list(request, conn=None, **kwargs):
 def api_experimenter_detail(request, experimenter_id, conn=None, **kwargs):
     # Validate parameter
     try:
-        experimenter_id = long(experimenter_id)
+        experimenter_id = int(experimenter_id)
     except ValueError:
         return HttpResponseBadRequest("Invalid experimenter id")
 
@@ -817,7 +811,7 @@ def api_image_list(request, conn=None, **kwargs):
     # a share connection in @login_required.
     # We don't support ?share_id in query string since this would allow a
     # share connection to be created for ALL urls, instead of just this one.
-    share_id = "share_id" in kwargs and long(kwargs["share_id"]) or None
+    share_id = "share_id" in kwargs and int(kwargs["share_id"]) or None
 
     try:
         # Get the images
@@ -964,25 +958,25 @@ def create_link(parent_type, parent_id, child_type, child_id):
             # dataset/plate is an orphan
             return "orphan"
     if parent_type == "project":
-        project = ProjectI(long(parent_id), False)
+        project = ProjectI(int(parent_id), False)
         if child_type == "dataset":
-            dataset = DatasetI(long(child_id), False)
+            dataset = DatasetI(int(child_id), False)
             link = ProjectDatasetLinkI()
             link.setParent(project)
             link.setChild(dataset)
             return link
     elif parent_type == "dataset":
-        dataset = DatasetI(long(parent_id), False)
+        dataset = DatasetI(int(parent_id), False)
         if child_type == "image":
-            image = ImageI(long(child_id), False)
+            image = ImageI(int(child_id), False)
             link = DatasetImageLinkI()
             link.setParent(dataset)
             link.setChild(image)
             return link
     elif parent_type == "screen":
-        screen = ScreenI(long(parent_id), False)
+        screen = ScreenI(int(parent_id), False)
         if child_type == "plate":
-            plate = PlateI(long(child_id), False)
+            plate = PlateI(int(child_id), False)
             link = ScreenPlateLinkI()
             link.setParent(screen)
             link.setChild(plate)
@@ -990,8 +984,8 @@ def create_link(parent_type, parent_id, child_type, child_id):
     elif parent_type == "tagset":
         if child_type == "tag":
             link = AnnotationAnnotationLinkI()
-            link.setParent(TagAnnotationI(long(parent_id), False))
-            link.setChild(TagAnnotationI(long(child_id), False))
+            link.setParent(TagAnnotationI(int(parent_id), False))
+            link.setChild(TagAnnotationI(int(child_id), False))
             return link
     return None
 
@@ -1020,11 +1014,7 @@ def api_links(request, conn=None, **kwargs):
             {"Error": "Need to POST or DELETE JSON data to update links"}, status=405
         )
     # Handle link creation/deletion
-    try:
-        json_data = json.loads(request.body)
-    except TypeError:
-        # for Python 3.5
-        json_data = json.loads(bytes_to_native_str(request.body))
+    json_data = json.loads(request.body)
 
     if request.method == "POST":
         return _api_links_POST(conn, json_data)
@@ -1462,7 +1452,7 @@ def load_plate(request, o1_type=None, o1_id=None, conn=None, **kwargs):
     kw = dict()
     if o1_type is not None:
         if o1_id is not None and int(o1_id) > 0:
-            kw[str(o1_type)] = long(o1_id)
+            kw[str(o1_type)] = int(o1_id)
 
     try:
         manager = BaseContainer(conn, **kw)
@@ -1661,7 +1651,7 @@ def load_searching(request, form=None, conn=None, **kwargs):
                 if len(queryId) == 0:
                     continue
                 try:
-                    searchById = long(queryId)
+                    searchById = int(queryId)
                     if searchById in idSet:
                         continue
                     idSet.add(searchById)
@@ -1767,7 +1757,7 @@ def load_metadata_details(request, c_type, c_id, conn=None, share_id=None, **kwa
         form_comment = CommentAnnotationForm(initial=initial)
     else:
         try:
-            manager = BaseContainer(conn, **{str(c_type): long(c_id), "index": index})
+            manager = BaseContainer(conn, **{str(c_type): int(c_id), "index": index})
         except AttributeError as x:
             return handlerInternalError(request, x)
         if share_id is not None:
@@ -1805,7 +1795,7 @@ def load_metadata_preview(request, c_type, c_id, conn=None, share_id=None, **kwa
     # the index of a field within a well
     index = getIntOrDefault(request, "index", 0)
 
-    manager = BaseContainer(conn, **{str(c_type): long(c_id)})
+    manager = BaseContainer(conn, **{str(c_type): int(c_id)})
     if share_id:
         context["share"] = BaseShare(conn, share_id)
     if c_type == "well":
@@ -1864,7 +1854,7 @@ def load_metadata_hierarchy(request, c_type, c_id, conn=None, **kwargs):
     static tree.
     Used by an AJAX call from the metadata_general panel.
     """
-    manager = BaseContainer(conn, **{str(c_type): long(c_id)})
+    manager = BaseContainer(conn, **{str(c_type): int(c_id)})
 
     context = {"manager": manager}
     context["template"] = "webclient/annotations/metadata_hierarchy.html"
@@ -1888,7 +1878,7 @@ def load_metadata_acquisition(
             manager.getComments(c_id)
         else:
             template = "webclient/annotations/metadata_acquisition.html"
-            manager = BaseContainer(conn, **{str(c_type): long(c_id)})
+            manager = BaseContainer(conn, **{str(c_type): int(c_id)})
     except AttributeError as x:
         return handlerInternalError(request, x)
 
@@ -2853,7 +2843,7 @@ def edit_channel_names(request, imageId, conn=None, **kwargs):
         parentId = request.POST.get("parentId", None)
         if parentId is not None:
             ptype = parentId.split("-")[0].title()
-            pid = long(parentId.split("-")[1])
+            pid = int(parentId.split("-")[1])
             counts = conn.setChannelNames(ptype, [pid], nameDict, channelCount=sizeC)
     else:
         counts = conn.setChannelNames("Image", [image.getId()], nameDict)
@@ -2962,7 +2952,7 @@ def manage_action_containers(
         else:
             d = dict()
             for e in form.errors.items():
-                d.update({e[0]: unicode(e[1])})
+                d.update({e[0]: str(e[1])})
             rdict = {"bad": "true", "errs": d}
             return JsonResponse(rdict)
 
@@ -3054,7 +3044,7 @@ def manage_action_containers(
             else:
                 d = dict()
                 for e in form.errors.items():
-                    d.update({e[0]: unicode(e[1])})
+                    d.update({e[0]: str(e[1])})
                 rdict = {"bad": "true", "errs": d}
                 return JsonResponse(rdict)
         else:
@@ -3086,7 +3076,7 @@ def manage_action_containers(
             else:
                 d = dict()
                 for e in form.errors.items():
-                    d.update({e[0]: unicode(e[1])})
+                    d.update({e[0]: str(e[1])})
                 rdict = {"bad": "true", "errs": d}
                 return JsonResponse(rdict)
         else:
@@ -3250,7 +3240,7 @@ def omero_table(request, file_id, mtype=None, conn=None, **kwargs):
         pass
 
     # Check if file exists since _table_query() doesn't check
-    file_id = long(file_id)
+    file_id = int(file_id)
     orig_file = conn.getObject("OriginalFile", file_id)
     if orig_file is None:
         raise Http404("OriginalFile %s not found" % file_id)
@@ -3626,11 +3616,7 @@ def activities(request, conn=None, **kwargs):
         return rv
 
     elif request.method == "DELETE":
-        try:
-            json_data = json.loads(request.body)
-        except TypeError:
-            # for Python 3.5
-            json_data = json.loads(bytes_to_native_str(request.body))
+        json_data = json.loads(request.body)
         jobId = json_data.get("jobId", None)
         if jobId is not None:
             jobId = str(jobId)
@@ -3872,7 +3858,7 @@ def activities(request, conn=None, **kwargs):
                         if cb.returncode != 0:
                             kwargs["Message"] = (
                                 f"Script exited with failure."
-                                f" (returncode={ cb.returncode })"
+                                f" (returncode={cb.returncode})"
                             )
                         update_callback(request, cbString, **kwargs)
                         new_results.append(cbString)
@@ -4116,7 +4102,7 @@ def script_ui(request, scriptId, conn=None, **kwargs):
     scriptService = conn.getScriptService()
 
     try:
-        params = scriptService.getParams(long(scriptId))
+        params = scriptService.getParams(int(scriptId))
     except Exception as ex:
         if ex.message.lower().startswith("no processor available"):
             return {
@@ -4129,7 +4115,7 @@ def script_ui(request, scriptId, conn=None, **kwargs):
 
     paramData = {}
 
-    paramData["id"] = long(scriptId)
+    paramData["id"] = int(scriptId)
     paramData["name"] = params.name.replace("_", " ")
     paramData["description"] = params.description
     paramData["authors"] = ", ".join([a for a in params.authors])
@@ -4166,7 +4152,7 @@ def script_ui(request, scriptId, conn=None, **kwargs):
                 i["default"] = ",".join([str(d) for d in i["default"]])
         elif isinstance(pt, bool):
             i["boolean"] = True
-        elif isinstance(pt, int) or isinstance(pt, long):
+        elif isinstance(pt, int) or isinstance(pt, int):
             # will stop the user entering anything other than numbers.
             i["number"] = "number"
         elif isinstance(pt, float):
@@ -4203,7 +4189,7 @@ def script_ui(request, scriptId, conn=None, **kwargs):
         # if we've not found a match, check whether we have "Well" selected
         if len(IDsParam["default"]) == 0 and request.GET.get("Well", None) is not None:
             if "Image" in Data_TypeParam["options"]:
-                wellIds = [long(j) for j in request.GET.get("Well", None).split(",")]
+                wellIds = [int(j) for j in request.GET.get("Well", None).split(",")]
                 wellIdx = 0
                 try:
                     wellIdx = int(request.GET.get("Index", 0))
@@ -4252,7 +4238,7 @@ def figure_script(request, scriptName, conn=None, **kwargs):
     wellIds = request.GET.get("Well", None)
 
     if wellIds is not None:
-        wellIds = [long(i) for i in wellIds.split(",")]
+        wellIds = [int(i) for i in wellIds.split(",")]
         wells = conn.getObjects("Well", wellIds)
         wellIdx = getIntOrDefault(request, "Index", 0)
         imageIds = [str(w.getImage(wellIdx).getId()) for w in wells]
@@ -4677,7 +4663,7 @@ def chgrp(request, conn=None, **kwargs):
     group_id = getIntOrDefault(request, "group_id", None)
     if group_id is None:
         return JsonResponse({"Error": "chgrp: No group_id specified"})
-    group_id = long(group_id)
+    group_id = int(group_id)
 
     def getObjectOwnerId(r):
         for t in ["Dataset", "Image", "Plate"]:
@@ -4749,15 +4735,15 @@ def getObjectOwnerId(r):
     plate_ids = request.POST.get("Plate", [])
 
     if project_ids:
-        project_ids = [long(x) for x in project_ids.split(",")]
+        project_ids = [int(x) for x in project_ids.split(",")]
     if dataset_ids:
-        dataset_ids = [long(x) for x in dataset_ids.split(",")]
+        dataset_ids = [int(x) for x in dataset_ids.split(",")]
     if image_ids:
-        image_ids = [long(x) for x in image_ids.split(",")]
+        image_ids = [int(x) for x in image_ids.split(",")]
     if screen_ids:
-        screen_ids = [long(x) for x in screen_ids.split(",")]
+        screen_ids = [int(x) for x in screen_ids.split(",")]
     if plate_ids:
-        plate_ids = [long(x) for x in plate_ids.split(",")]
+        plate_ids = [int(x) for x in plate_ids.split(",")]
 
     # TODO Change this user_id to be an experimenter_id in the request as it
     # is possible that a user is chgrping data from another user so it is
@@ -4833,7 +4819,7 @@ def script_run(request, scriptId, conn=None, **kwargs):
 
     inputMap = {}
 
-    sId = long(scriptId)
+    sId = int(scriptId)
 
     try:
         params = scriptService.getParams(sId)
@@ -4911,8 +4897,6 @@ def script_run(request, scriptId, conn=None, **kwargs):
                     listClass = pval[0].__class__
                     if listClass == int(1).__class__:
                         listClass = omero.rtypes.rint
-                    if listClass == long(1).__class__:
-                        listClass = omero.rtypes.rlong
 
                 # construct our list, using appropriate 'type'
                 valueList = []
@@ -5018,7 +5002,7 @@ def ome_tiff_script(request, imageId, conn=None, **kwargs):
     if image is not None:
         gid = image.getDetails().group.id.val
         conn.SERVICE_OPTS.setOmeroGroup(gid)
-    imageIds = [long(imageId)]
+    imageIds = [int(imageId)]
     inputMap = {
         "Data_Type": wrap("Image"),
         "IDs": rlist([rlong(id) for id in imageIds]),
@@ -5050,8 +5034,7 @@ def run_script(request, conn, sId, inputMap, scriptName="Script"):
         request.session.modified = True
     except Exception as x:
         jobId = str(time())  # E.g. 1312803670.6076391
-        # handle python 2 or 3 errors
-        message = x.message if hasattr(x, "message") else (x.args[0] if x.args else "")
+        message = x.args[0] if x.args else ""
         if message and message.startswith("No processor available"):
             # omero.ResourceError
             logger.info(traceback.format_exc())
diff --git a/omeroweb/webclient/webclient_gateway.py b/omeroweb/webclient/webclient_gateway.py
index a6b68cdede..8b91b9ab1c 100644
--- a/omeroweb/webclient/webclient_gateway.py
+++ b/omeroweb/webclient/webclient_gateway.py
@@ -57,31 +57,14 @@
     lengthformat,
 )
 
-NSEXPERIMENTERPHOTO = omero.constants.namespaces.NSEXPERIMENTERPHOTO
-
-try:
-    import hashlib
+from hashlib import sha1 as hash_sha1
+from PIL import Image
 
-    hash_sha1 = hashlib.sha1
-except Exception:
-    import sha
 
-    hash_sha1 = sha.new
+NSEXPERIMENTERPHOTO = omero.constants.namespaces.NSEXPERIMENTERPHOTO
 
 logger = logging.getLogger(__name__)
 
-try:
-    from PIL import Image  # see ticket:2597
-except ImportError:
-    try:
-        import Image  # see ticket:2597
-    except Exception:
-        logger.error(
-            "You need to install the Python Imaging Library. Get it at"
-            " http://www.pythonware.com/products/pil/"
-        )
-        logger.error(traceback.format_exc())
-
 
 def defaultThumbnail(size=(120, 120)):
     if isinstance(size, int):
diff --git a/omeroweb/webgateway/marshal.py b/omeroweb/webgateway/marshal.py
index 80008ea3c2..65b32c3ec3 100644
--- a/omeroweb/webgateway/marshal.py
+++ b/omeroweb/webgateway/marshal.py
@@ -23,7 +23,6 @@
 import re
 import logging
 import traceback
-from future.utils import isbytes, bytes_to_native_str
 
 from omero.rtypes import unwrap
 from omero_marshal import get_encoder
@@ -296,8 +295,8 @@ def set_if(k, v, func=lambda a: a is not None):
 
     def decode(shape_field):
         value = shape_field.getValue()
-        if value and isbytes(value):
-            value = bytes_to_native_str(value)
+        if value and isinstance(value, bytes):
+            value = value.decode("utf-8")
         return value
 
     rv["id"] = shape.getId().getValue()
@@ -380,7 +379,6 @@ def decode(shape_field):
         # FIXME: units ignored for stroke width
         set_if("strokeWidth", shape.getStrokeWidth().getValue())
     if hasattr(shape, "getMarkerStart") and shape.getMarkerStart() is not None:
-        # Handle string for python2 and bytes python3. TODO: lower level fix
         rv["markerStart"] = decode(shape.getMarkerStart())
     if hasattr(shape, "getMarkerEnd") and shape.getMarkerEnd() is not None:
         rv["markerEnd"] = decode(shape.getMarkerEnd())
diff --git a/omeroweb/webgateway/plategrid.py b/omeroweb/webgateway/plategrid.py
index 3cacbe686f..636eb56d15 100644
--- a/omeroweb/webgateway/plategrid.py
+++ b/omeroweb/webgateway/plategrid.py
@@ -15,10 +15,6 @@
 import omero.sys
 from omero.rtypes import rint
 
-try:
-    import long
-except ImportError:
-    long = int
 
 logger = logging.getLogger(__name__)
 
@@ -36,7 +32,7 @@ def __init__(self, conn, pid, fid, thumbprefix="", plate_layout=None):
         param:  plate_layout is "expand" to expand plate to multiple of 8 x 12
                 or "shrink" to ignore all wells before the first row/column
         """
-        self.plate = conn.getObject("plate", long(pid))
+        self.plate = conn.getObject("plate", int(pid))
         self._conn = conn
         self.field = fid
         self._thumbprefix = thumbprefix
diff --git a/omeroweb/webgateway/templatetags/common_filters.py b/omeroweb/webgateway/templatetags/common_filters.py
index a660b8d7f3..939054007d 100644
--- a/omeroweb/webgateway/templatetags/common_filters.py
+++ b/omeroweb/webgateway/templatetags/common_filters.py
@@ -31,8 +31,6 @@
 import json
 import random
 
-from past.builtins import basestring
-
 from django import template
 
 register = template.Library()
@@ -112,7 +110,7 @@ def truncateafter(value, arg):
         length = int(arg)
     except ValueError:  # invalid literal for int()
         return value  # Fail silently.
-    if not isinstance(value, basestring):
+    if not isinstance(value, str):
         value = str(value)
     if len(value) > length:
         return value[:length] + "..."
@@ -130,7 +128,7 @@ def truncatebefor(value, arg):
         length = int(arg)
     except ValueError:  # invalid literal for int()
         return value  # Fail silently.
-    if not isinstance(value, basestring):
+    if not isinstance(value, str):
         value = str(value)
     if len(value) > length:
         return "..." + value[len(value) - length :]
@@ -146,7 +144,7 @@ def shortening(value, arg):
         return value  # Fail silently.
     chunk = length // 2 - 3
 
-    if not isinstance(value, basestring):
+    if not isinstance(value, str):
         value = str(value)
     try:
         if len(value) < length:
diff --git a/omeroweb/webgateway/templatetags/defaulttags.py b/omeroweb/webgateway/templatetags/defaulttags.py
index 1c99ee12ec..3975ad5314 100644
--- a/omeroweb/webgateway/templatetags/defaulttags.py
+++ b/omeroweb/webgateway/templatetags/defaulttags.py
@@ -26,12 +26,7 @@
 
 import logging
 
-try:
-    # python2
-    from urlparse import urljoin
-except ImportError:
-    # python3
-    from urllib.parse import urljoin
+from urllib.parse import urljoin
 
 from django import template
 from django.templatetags.static import PrefixNode
diff --git a/omeroweb/webgateway/util.py b/omeroweb/webgateway/util.py
index 7a3a2468c6..d7954a3f3f 100644
--- a/omeroweb/webgateway/util.py
+++ b/omeroweb/webgateway/util.py
@@ -23,11 +23,6 @@
 import shutil
 import logging
 
-try:
-    import long
-except ImportError:
-    long = int
-
 logger = logging.getLogger(__name__)
 
 LUTS_IN_PNG = [
@@ -95,7 +90,7 @@ def get_longs(request, name):
     vals = []
     vals_raw = request.GET.getlist(name)
     for val_raw in vals_raw:
-        vals.append(long(val_raw))
+        vals.append(int(val_raw))
     return vals
 
 
diff --git a/omeroweb/webgateway/views.py b/omeroweb/webgateway/views.py
index d564a727fe..c9d5b94ff8 100644
--- a/omeroweb/webgateway/views.py
+++ b/omeroweb/webgateway/views.py
@@ -20,7 +20,6 @@
 from functools import wraps
 import omero
 import omero.clients
-from past.builtins import unicode
 
 from django.http import (
     HttpResponse,
@@ -61,11 +60,6 @@
 except Exception:
     from md5 import md5
 
-try:
-    import long
-except ImportError:
-    long = int
-
 from io import BytesIO
 import tempfile
 
@@ -96,26 +90,12 @@
 from omeroweb.webgateway.util import zip_archived_files, LUTS_IN_PNG
 from omeroweb.webgateway.util import get_longs, getIntOrDefault
 
-cache = CacheBase()
-logger = logging.getLogger(__name__)
+from PIL import Image, ImageDraw
+import numpy
 
-try:
-    from PIL import Image
-    from PIL import ImageDraw
-except Exception:  # pragma: nocover
-    try:
-        import Image
-        import ImageDraw
-    except Exception:
-        logger.error("No Pillow installed")
-
-try:
-    import numpy
 
-    numpyInstalled = True
-except ImportError:
-    logger.error("No numpy installed")
-    numpyInstalled = False
+cache = CacheBase()
+logger = logging.getLogger(__name__)
 
 
 def index(request):
@@ -124,7 +104,7 @@ def index(request):
 
 
 def _safestr(s):
-    return unicode(s).encode("utf-8")
+    return str(s).encode("utf-8")
 
 
 class UserProxy(object):
@@ -476,7 +456,7 @@ def render_roi_thumbnail(request, roiId, w=None, h=None, conn=None, **kwargs):
     server_id = request.session["connector"]["server_id"]
 
     # need to find the z indices of the first shape in T
-    result = conn.getRoiService().findByRoi(long(roiId), None, conn.SERVICE_OPTS)
+    result = conn.getRoiService().findByRoi(int(roiId), None, conn.SERVICE_OPTS)
     if result is None or result.rois is None or len(result.rois) == 0:
         raise Http404
 
@@ -805,8 +785,6 @@ def resizeXY(xy):
 def render_shape_mask(request, shapeId, conn=None, **kwargs):
     """Returns mask as a png (supports transparency)"""
 
-    if not numpyInstalled:
-        raise NotImplementedError("numpy not installed")
     params = omero.sys.Parameters()
     params.map = {"id": rlong(shapeId)}
     shape = conn.getQueryService().findByQuery(
@@ -982,8 +960,8 @@ def _get_prepared_image(
     img.setInvertedAxis(bool(r.get("ia", "0") == "1"))
     compress_quality = r.get("q", None)
     if saveDefs:
-        "z" in r and img.setDefaultZ(long(r["z"]) - 1)
-        "t" in r and img.setDefaultT(long(r["t"]) - 1)
+        "z" in r and img.setDefaultZ(int(r["z"]) - 1)
+        "t" in r and img.setDefaultT(int(r["t"]) - 1)
         img.saveDefaults()
     return (img, compress_quality)
 
@@ -1672,7 +1650,7 @@ def plateGrid_json(request, pid, field=0, conn=None, **kwargs):
     Or "trim" to neither expand nor shrink
     """
     try:
-        field = long(field or 0)
+        field = int(field or 0)
     except ValueError:
         field = 0
     prefix = kwargs.get("thumbprefix", "webgateway_render_thumbnail")
@@ -1987,7 +1965,7 @@ def searchOptFromRequest(request):
     try:
         r = request.GET
         opts = {
-            "search": unicode(r.get("text", "")).encode("utf8"),
+            "search": str(r.get("text", "")).encode("utf8"),
             "ctx": r.get("ctx", ""),
             "grabData": not not r.get("grabData", False),
             "parents": not not bool(r.get("parents", False)),
@@ -2178,7 +2156,7 @@ def list_compatible_imgs_json(request, iid, conn=None, **kwargs):
         img_ew.sort()
 
         def compat(i):
-            if long(i.getId()) == long(iid):
+            if int(i.getId()) == int(iid):
                 return False
             pp = i.getPrimaryPixels()
             if (
@@ -2287,9 +2265,9 @@ def applyRenderingSettings(image, rdef):
     else:
         image.setColorRenderingModel()
     if "z" in rdef:
-        image._re.setDefaultZ(long(rdef["z"]) - 1)
+        image._re.setDefaultZ(int(rdef["z"]) - 1)
     if "t" in rdef:
-        image._re.setDefaultT(long(rdef["t"]) - 1)
+        image._re.setDefaultT(int(rdef["t"]) - 1)
     image.saveDefaults()
 
 
@@ -2385,8 +2363,8 @@ def copy_image_rdef_json(request, conn=None, **kwargs):
 
         # If we have both, apply settings...
         try:
-            fromid = long(fromid)
-            toids = [long(x) for x in toids]
+            fromid = int(fromid)
+            toids = [int(x) for x in toids]
         except TypeError:
             fromid = None
         except ValueError:
@@ -2803,9 +2781,9 @@ def get_rois_json(request, imageId, conn=None, **kwargs):
     """
     rois = []
     roiService = conn.getRoiService()
-    # rois = webfigure_utils.getRoiShapes(roiService, long(imageId))  # gets a
+    # rois = webfigure_utils.getRoiShapes(roiService, int(imageId))  # gets a
     # whole json list of ROIs
-    result = roiService.findByImage(long(imageId), None, conn.SERVICE_OPTS)
+    result = roiService.findByImage(int(imageId), None, conn.SERVICE_OPTS)
 
     for r in result.rois:
         roi = {}
@@ -3233,8 +3211,6 @@ def obj_id_bitmask(request, fileid, conn=None, query=None, **kwargs):
                         or with an array of bytes as described above
     """
 
-    if not numpyInstalled:
-        raise NotImplementedError("numpy not installed")
     col_name = request.GET.get("col_name", "object")
     if query is None:
         query = request.GET.get("query")
diff --git a/omeroweb/webgateway/webgateway_cache.py b/omeroweb/webgateway/webgateway_cache.py
index 576a50df30..43f4d3e7ef 100644
--- a/omeroweb/webgateway/webgateway_cache.py
+++ b/omeroweb/webgateway/webgateway_cache.py
@@ -19,12 +19,6 @@
 from random import random
 from io import open
 import datetime
-
-# Support python2 and python3
-from past.builtins import basestring
-from builtins import str
-
-
 import struct
 import time
 import os
@@ -146,7 +140,7 @@ def set(self, key, value, timeout=None, invalidateGroup=None):
         @param invalidateGroup:     Not used?
         """
 
-        if not isinstance(value, basestring):
+        if not isinstance(value, str):
             raise ValueError("%s not a string, can't cache" % type(value))
         fname = self._key_to_file(key)
         dirname = os.path.dirname(fname)
@@ -231,7 +225,7 @@ def _check_entry(self, fname):
         @return True if entry is valid, False if expired
         """
         try:
-            if isinstance(fname, basestring):
+            if isinstance(fname, str):
                 f = open(fname, "rb")
                 exp = struct.unpack("d", f.read(size_of_double))[0]
             else:
diff --git a/setup.py b/setup.py
index 65f1d02a76..71bf4abe85 100755
--- a/setup.py
+++ b/setup.py
@@ -51,7 +51,7 @@ def read(fname):
     python_requires=">=3.8",
     install_requires=[
         # requires Ice (use wheel for faster installs)
-        "omero-py>=5.7.0",
+        "omero-py>=5.19.0",
         # minimum requirements for `omero web start`
         "concurrent-log-handler>=0.9.20",
         "Django>=4.2.3,<4.3",
diff --git a/tox.ini b/tox.ini
index e648cbeecc..c943243656 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 # Default tox environment when run without `-e`
-envlist = py38, py39, py310, py311
+envlist = py38, py39, py310, py311, py312
 
 # https://tox.readthedocs.io/en/latest/config.html#conf-requires
 # Ensure pip is new so we can install manylinux wheel
@@ -9,8 +9,8 @@ requires = pip >= 19.0.0
 
 [testenv]
 deps =
+    setuptools
     pre-commit
-    future
     numpy>=1.9
     pytest
     PyYAML
@@ -21,8 +21,8 @@ deps =
     https://github.com/ome/zeroc-ice-py-github-ci/releases/download/0.2.0/zeroc_ice-3.6.5-cp38-cp38-linux_x86_64.whl; platform_system=="Linux" and python_version=="3.8"
     https://github.com/ome/zeroc-ice-py-github-ci/releases/download/0.2.0/zeroc_ice-3.6.5-cp39-cp39-linux_x86_64.whl; platform_system=="Linux" and python_version=="3.9"
     https://github.com/ome/zeroc-ice-py-github-ci/releases/download/0.2.0/zeroc_ice-3.6.5-cp310-cp310-linux_x86_64.whl; platform_system=="Linux" and python_version=="3.10"
-    https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20231024/zeroc_ice-3.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl; platform_system=="Linux" and python_version=="3.11"
-
+    https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20240202/zeroc_ice-3.6.5-cp311-cp311-manylinux_2_28_x86_64.whl; platform_system=="Linux" and python_version=="3.11"
+    https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20240202/zeroc_ice-3.6.5-cp312-cp312-manylinux_2_28_x86_64.whl; platform_system=="Linux" and python_version=="3.12"
 setenv =
     OMERODIR = {toxinidir}
     DJANGO_SETTINGS_MODULE = omeroweb.settings


Test patch:
diff --git a/omeroweb/testlib/__init__.py b/omeroweb/testlib/__init__.py
index 80ac4ed313..90eda3cba3 100644
--- a/omeroweb/testlib/__init__.py
+++ b/omeroweb/testlib/__init__.py
@@ -20,9 +20,6 @@
 """
    Library for Web integration tests
 """
-from __future__ import print_function
-
-from future import standard_library
 import json
 import warnings
 
@@ -32,7 +29,6 @@
 
 from omero.testlib import ITest
 
-standard_library.install_aliases()  # noqa
 from urllib.parse import urlencode  # noqa
 
 
diff --git a/test.sh b/test.sh
deleted file mode 100755
index ca4545248c..0000000000
--- a/test.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-set -e
-set -u
-set -x
-IMAGE=${IMAGE:-${USER}-web-test}
-PIP_CACHE_DIR=${PIP_CACHE_DIR:-/tmp/pip-cache}
-mkdir -m 777 -p ${PIP_CACHE_DIR}
-chmod a+t ${PIP_CACHE_DIR}
-docker build -t ${IMAGE} .
-docker run -ti --rm -v ${PIP_CACHE_DIR}:/tmp/pip-cache ${IMAGE} "$@"
diff --git a/test/unit/test_web.py b/test/unit/test_web.py
index fc09995682..bfb1a7894d 100644
--- a/test/unit/test_web.py
+++ b/test/unit/test_web.py
@@ -23,13 +23,8 @@
 from difflib import unified_diff
 import re
 import os
-import sys
 
-try:
-    from omero_ext.path import path
-except ImportError:
-    # Python 2
-    from path import path
+from omero_ext.path import path
 import getpass
 import Ice
 import omero.cli
@@ -572,7 +567,6 @@ def setup_method(self, method):
         self.cli.register("config", PrefsControl, "TEST")
         self.args = ["config"]
 
-    @pytest.mark.xfail(sys.version_info < (3, 0), reason="py2 unicode issue")
     def test_parse(self):
         self.args += ["parse", "--rst"]
         self.cli.invoke(self.args, strict=True)