Repository: learningequality/morango (language: python)

Issue:
Add Python 3.13 support
```[tasklist]
### Tasks
- [x] Add support for the as yet unreleased Python 3.13
- [x] Add to CI version matrix
- [ ] Ensure all tests pass
```



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index 66eee24..c0c0177 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -22,7 +22,7 @@ jobs:
         strategy:
             max-parallel: 5
             matrix:
-                python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10', '3.11', '3.12' ]
+                python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10', '3.11', '3.12', '3.13' ]
 
         steps:
             - uses: actions/checkout@v2
@@ -54,7 +54,7 @@ jobs:
         strategy:
             max-parallel: 5
             matrix:
-                python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10', '3.11', '3.12' ]
+                python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10', '3.11', '3.12', '3.13' ]
 
         steps:
             - uses: actions/checkout@v2
diff --git a/setup.py b/setup.py
index d867f6f..daedec9 100644
--- a/setup.py
+++ b/setup.py
@@ -46,6 +46,7 @@
         'Programming Language :: Python :: 3.10',
         'Programming Language :: Python :: 3.11',
         'Programming Language :: Python :: 3.12',
+        'Programming Language :: Python :: 3.13',
     ],
-    python_requires=">=3.6,  <3.13",
+    python_requires=">=3.6,  <3.14",
 )
diff --git a/tox.ini b/tox.ini
index 5b64f89..46e0408 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 envlist =
-  py{3.6,3.7,3.8,3.9,3.10,3.11,3.12}-cryptography{40.0.2}
+  py{3.6,3.7,3.8,3.9,3.10,3.11,3.12,3.13}-cryptography{40.0.2}
   postgres
   windows
 
@@ -20,6 +20,7 @@ basepython =
   py3.10: python3.10
   py3.11: python3.11
   py3.12: python3.12
+  py3.13: python3.13
   postgres: python3.9
   windows: python3.8
 


Test patch:
diff --git a/tests/testapp/testapp/__init__.py b/tests/testapp/testapp/__init__.py
index 150cbd4..d06f1b5 100644
--- a/tests/testapp/testapp/__init__.py
+++ b/tests/testapp/testapp/__init__.py
@@ -1,76 +1,17 @@
 import sys
 
 
-def monkey_patch_collections():
+def forward_port_cgi_module():
     """
-    Monkey-patching for the collections module is required for Python 3.10
-    and above.
-    Prior to 3.10, the collections module still contained all the entities defined in
-    collections.abc from Python 3.3 onwards. Here we patch those back into main
-    collections module.
-    This can be removed when we upgrade to a version of Django that is Python 3.10 compatible.
-    Copied from:
-    https://github.com/learningequality/kolibri/blob/589dd15aa79e8694aff8754bb34f12384315dbb6/kolibri/utils/compat.py#L90
+    Forward ports the required parts of the removed cgi module.
+    This can be removed when we upgrade to a version of Django that is Python 3.13 compatible.
     """
-    if sys.version_info < (3, 10):
+    if sys.version_info < (3, 13):
         return
-    import collections
-    from collections import abc
+    from importlib import import_module
 
-    for name in dir(abc):
-        if not hasattr(collections, name):
-            setattr(collections, name, getattr(abc, name))
+    module = import_module("testapp.cgi")
+    sys.modules["cgi"] = module
 
 
-monkey_patch_collections()
-
-
-def monkey_patch_translation():
-    """
-    Monkey-patching for the gettext module is required for Python 3.11
-    and above.
-    Prior to 3.11, the gettext module classes still had the deprecated set_output_charset
-    This can be removed when we upgrade to a version of Django that no longer relies
-    on this deprecated Python 2.7 only call.
-    Copied from:
-    https://github.com/learningequality/kolibri/blob/589dd15aa79e8694aff8754bb34f12384315dbb6/kolibri/utils/compat.py#L109
-    """
-    if sys.version_info < (3, 11):
-        return
-
-    import gettext
-
-    def set_output_charset(*args, **kwargs):
-        pass
-
-    gettext.NullTranslations.set_output_charset = set_output_charset
-
-    original_translation = gettext.translation
-
-    def translation(
-        domain,
-        localedir=None,
-        languages=None,
-        class_=None,
-        fallback=False,
-        codeset=None,
-    ):
-        return original_translation(
-            domain,
-            localedir=localedir,
-            languages=languages,
-            class_=class_,
-            fallback=fallback,
-        )
-
-    gettext.translation = translation
-
-    original_install = gettext.install
-
-    def install(domain, localedir=None, codeset=None, names=None):
-        return original_install(domain, localedir=localedir, names=names)
-
-    gettext.install = install
-
-
-monkey_patch_translation()
+forward_port_cgi_module()
diff --git a/tests/testapp/testapp/cgi.py b/tests/testapp/testapp/cgi.py
new file mode 100644
index 0000000..a825ef5
--- /dev/null
+++ b/tests/testapp/testapp/cgi.py
@@ -0,0 +1,47 @@
+"""
+A minimal port of the removed cgi module for use in Python 3.13.
+Only imports the specific parts of the module that are used by Django.
+Informed by the PR that removed its use in Django:
+https://github.com/django/django/pull/15679
+"""
+from django.utils.regex_helper import _lazy_re_compile
+
+
+def _parseparam(s):
+    while s[:1] == ";":
+        s = s[1:]
+        end = s.find(";")
+        while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
+            end = s.find(";", end + 1)
+        if end < 0:
+            end = len(s)
+        f = s[:end]
+        yield f.strip()
+        s = s[end:]
+
+
+def parse_header(line):
+    """
+    Parse a Content-type like header.
+    Return the main content-type and a dictionary of options.
+    """
+    parts = _parseparam(";" + line)
+    key = parts.__next__()
+    pdict = {}
+    for p in parts:
+        i = p.find("=")
+        if i >= 0:
+            name = p[:i].strip().lower()
+            value = p[i + 1 :].strip()
+            if len(value) >= 2 and value[0] == value[-1] == '"':
+                value = value[1:-1]
+                value = value.replace("\\\\", "\\").replace('\\"', '"')
+            pdict[name] = value
+    return key, pdict
+
+
+boundary_re = _lazy_re_compile(rb"[ -~]{0,200}[!-~]")
+
+
+def valid_boundary(boundary):
+    return boundary_re.fullmatch(boundary) is not None