Repository: ImperialCollegeLondon/django-drf-filepond (language: python)

Issue:
GHA issues with older versions of Python
In the updated test configuration using tox and running across multiple Python versions, the attempt to run on Python 2.7, 3.5 and 3.6 fails. This seems to be because, when using `ubuntu-latest` for the OS, we're getting 22.04 runners which don't have these older versions of Python available.

This seems to be a common issue discussed in issue 544 of the [setup-python repo](https://github.com/actions/setup-python/issues/). One temporary workaround seems to be to switch the version of Ubuntu being used to 20.04. This is an option but ideally, it would be good to be running on 22.04 where possible.

There are a couple of options here, one is simply to switch, for now to 20.04. The other is to update the GHA matrix to something similar to the example shown in https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#matrix-testing.

Here we could put an exclude on 20.04 for the newer versions and an exclude on 22.04 for the older versions. This may be a little messy to maintain but, for now, while the older Python versions are being supported, it may be more sustainable.


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 1329412..3413161 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,6 +2,11 @@
 omit = 
     django_drf_filepond/__init__.py
     django_drf_filepond/apps.py
+    django_drf_filepond/migrations/*
+    tests/*
+    ; Prevent coverage under py2.7 and py3.5 including venv files
+    .tox/*
+    env*
 
 [report]
 show_missing = True
diff --git a/.gitignore b/.gitignore
index f6519be..9a21309 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,10 +48,11 @@ coverage.xml
 # PyBuilder
 target/
 
-# Eclipse/PyDev project files
+# Eclipse/PyDev/VSCode project files
 .project
 .pydevproject
 .settings
+.vscode
 
 # Sphinx documentation files
 docs/_build/
diff --git a/django_drf_filepond/__init__.py b/django_drf_filepond/__init__.py
index 01bff18..3f2a60b 100644
--- a/django_drf_filepond/__init__.py
+++ b/django_drf_filepond/__init__.py
@@ -1,1 +1,4 @@
+# This setting is remaining in place to support older versions of Python/Django
+# however it is deprecated and removed in Django 4.1 where the setting
+# is detected automatically.
 default_app_config = 'django_drf_filepond.apps.DjangoDrfFilepondConfig'
diff --git a/requirements.txt b/requirements.txt
index 886977c..1add47c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ djangorestframework==3.9.4;python_version=="2.7"
 djangorestframework>=3.11.2;python_version>="3.5"
 django-storages==1.9.1;python_version=="2.7"
 django-storages>=1.9.1;python_version>="3.5"
-httpretty==1.0.3;python_version>="3.5"
+httpretty==1.1.4;python_version>="3.5"
 httpretty==0.9.7;python_version=="2.7"
 requests>=2.20.1
 shortuuid==0.5.0;python_version=="2.7"
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..2d8db07
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,59 @@
+[tox]
+; This tox configuration is based on examples at:
+; https://jugmac00.github.io/blog/combine-coverage-for-different-python-versions/
+; https://github.com/JulienPalard/oeis/blob/14bea829d556bd617426c8a85235ba5da1b9f157/tox.ini
+; Specify the environments to run - we run the required
+; Python/Django version combinations and we'll store code
+; coverage information for each (see testenv). Then we
+; run the coverage env target to combine coverage info.
+; Note that this has been updated to remove the use of pytest-cov
+; since this complicated parallel runs via GHA. Since we're no longer
+; using append (via pytest-cov), this means we also don't need an inital
+; coverage clean step since previous coverage files are overwritten.
+envlist = 
+    py27
+    py35
+    py36
+    py37
+    py38
+    py39
+    py310
+    coverage
+
+[testenv]
+; This is the general environment configuration for the
+; Python/Django test runs.
+deps =
+        pytest
+        pytest-django
+        coverage
+        wheel;python_version=='3.5'
+        httpretty==1.0.3;python_version>='3.5'
+        httpretty==0.9.7;python_version=='2.7'
+        mock>=3.0.0,<=3.0.5;python_version<='3.5'
+        mock>=3.0.5;python_version>'3.5'
+        paramiko
+
+; Run coverage with the parallel option to produce
+; coverage files that don't overwrite each other!
+commands =
+    {envbindir}/coverage run -m -p pytest 
+
+[testenv:coverage]
+deps = coverage
+parallel_show_output = True
+skip_install = true
+commands =
+    coverage combine
+    coverage report -m
+depends =
+    py27
+    py35
+    py36
+    py37
+    py38
+    py39
+    py310
+
+
+; pytest configuration is provided in pytest.ini


Test patch:
diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml
index 1cfdbb7..7ab87bc 100644
--- a/.github/workflows/python-test.yml
+++ b/.github/workflows/python-test.yml
@@ -1,7 +1,15 @@
 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
-
-name: test
+#
+# The pipeline generates coverage data for each Python version that
+# the tests are run against and then uploads this data as artifacts.
+# The coverage step then pulls down this data combines it and generates
+# the coverage report.
+# See https://nedbatchelder.com/blog/202209/making_a_coverage_badge.html or
+# or https://hynek.me/articles/ditch-codecov-python/ for examples of 
+# similar approaches to generating and combining coverage data on which
+# this file is based.
+name: "Run tests"
 
 on:
   push:
@@ -12,18 +20,34 @@ on:
     types: [created, edited, published]
 
 jobs:
-  build:
+  tests:
 
-    runs-on: ubuntu-latest
+    runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
       matrix:
-        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
+        os: [ubuntu-20.04, ubuntu-latest]
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, '3.10']
+        exclude:
+          - os: ubuntu-20.04
+            python-version: '3.7'
+          - os: ubuntu-20.04
+            python-version: '3.8'
+          - os: ubuntu-20.04
+            python-version: '3.9'
+          - os: ubuntu-20.04
+            python-version: '3.10'
+          - os: ubuntu-latest
+            python-version: '2.7'
+          - os: ubuntu-latest
+            python-version: '3.5'
+          - os: ubuntu-latest
+            python-version: '3.6'
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
@@ -32,7 +56,7 @@ jobs:
         python -m pip install flake8
         # Also upgrade setuptools to resolve issue with "no module named setuptools_rust" error on 2.7/3.6
         python -m pip install --upgrade setuptools
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+        python -m pip install tox
     - name: Lint with flake8
       run: |
         # stop the build if there are Python syntax errors or undefined names
@@ -40,6 +64,46 @@ jobs:
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
         # Aim to reduce complexity to 10 but leave at 16 for now to support existing code
         flake8 django_drf_filepond --count --exit-zero --max-complexity=16 --max-line-length=80 --statistics
-    - name: Test with nose
+    - name: Test with tox/pytest
       run: |
-        python setup.py test
+        # We use the default "py" environment since this will use the version
+        # of Python installed in the runner which will be the version from the
+        # matrix for this task.
+        tox -e py
+    - name: Upload coverage data
+      uses: actions/upload-artifact@v3
+      with:
+        name: coverage-data
+        path: .coverage.*
+        retention-days: 1
+
+  # Once the tests have been run on the different Python versions and
+  # the coverage data files uploaded as artifacts, we can now pull
+  # down the coverage data from the various jobs that may have run in 
+  # parallel and we can combine the data to get a final coverage value.
+  # We then generate a badge which is stored to the repo for display in
+  # the main README.
+  coverage:
+    name: "Code coverage"
+    needs: tests
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python 3.8
+        uses: actions/setup-python@v4
+        with:
+          python-version: "3.8"
+      
+      - name: "Install dependencies"
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install tox
+      
+      - name: "Download coverage data"
+        uses: actions/download-artifact@v3
+        with:
+          name: coverage-data
+      
+      - name: "Combine coverage data and show report"
+        run: |
+          tox -e coverage
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..1c54027
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,16 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = tests.settings
+; addopts = --cov=django_drf_filepond
+testpaths =
+    tests
+
+filterwarnings =
+    ; Ignore the RemovedInDjango41Warning generated by the use of default_app_config
+    ignore:.*Django now detects this configuration automatically:PendingDeprecationWarning
+    ; Ignore the RemovedInDjango5Warning generated by the use of the USE_L10N setting since this
+    ; only applies to the test settings and not the app itself.
+    ignore:.*The USE_L10N setting is deprecated\. Starting with Django 5.0:PendingDeprecationWarning
+    ; Ignore CryptographyDeprecationWarnings for removal of cryptography support for py2.7, 3.5, 3.6
+    ignore:.*Python 2 is no longer supported by the Python core team\. Support for it is now deprecated in cryptography:UserWarning
+    ignore:.*Python 3\.5 support will be dropped in the next release of cryptography:UserWarning
+    ignore:.*Python 3\.6 is no longer supported by the Python core team\. Therefore, support for it is deprecated in cryptography:UserWarning
diff --git a/tests/__init__.py b/tests/__init__.py
index 8b2e2b4..e69de29 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,27 +0,0 @@
-# Test initialisation based on details provided at
-# https://django-module-crash-course.readthedocs.io/en/latest/03-testing.html
-import os
-import django
-
-test_runner = None
-old_config = None
-
-os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
-
-if hasattr(django, "setup"):
-    django.setup()
-
-
-def setup():
-    global test_runner
-    global old_config
-
-    from django.test.runner import DiscoverRunner
-    test_runner = DiscoverRunner()
-    test_runner.setup_test_environment()
-    old_config = test_runner.setup_databases()
-
-
-def teardown():
-    test_runner.teardown_databases(old_config)
-    test_runner.teardown_test_environment()
diff --git a/tests/runner.py b/tests/runner.py
deleted file mode 100644
index d2056d4..0000000
--- a/tests/runner.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env python
-
-from __future__ import absolute_import, print_function, unicode_literals
-
-import os
-import sys
-import nose
-
-
-def start(argv=None):
-    sys.exitfunc = lambda: sys.stderr.write("Shutting down...\n")
-
-    if argv is None:
-        argv = [
-            "nosetests", "--cover-branches", "--with-coverage",
-            "--cover-erase", "--verbose",
-            "--cover-package=django_drf_filepond",
-        ]
-#        argv = [
-#            "nosetests", "--cover-branches", "--verbose",
-#            "--cover-package=django_drf_filepond",
-#        ]
-
-    nose.run_exit(argv=argv,
-                  defaultTest=os.path.abspath(os.path.dirname(__file__)))
-
-
-if __name__ == "__main__":
-    start(sys.argv)
diff --git a/tests/test_fetch_view.py b/tests/test_fetch_view.py
index 824a65b..9e61cb1 100644
--- a/tests/test_fetch_view.py
+++ b/tests/test_fetch_view.py
@@ -139,12 +139,9 @@ def connection_error_callback(request, uri, response_headers):
                      status=200,
                      body=connection_error_callback)
 
-        response = self.client.get((reverse('fetch') +
-                                    ('?target=%s' % test_url)))
-        self.assertContains(
-            response,
-            'Unable to access the requested remote file headers',
-            status_code=500)
+        with self.assertRaises(requests.exceptions.ConnectionError):
+            requests.get('http://localhost' + (reverse('fetch') +
+                         ('?target=%s' % test_url)))
 
     @httpretty.activate
     def test_fetch_file_notfound_error(self):
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
new file mode 100644
index 0000000..2e39bfe
--- /dev/null
+++ b/tests/test_parsers.py
@@ -0,0 +1,35 @@
+import os
+from io import BytesIO
+from django_drf_filepond.parsers import UploadChunkParser
+from django.test.testcases import TestCase
+
+#########################################################################
+# Test any custom parsers within parsers.py
+#
+# test_upload_chunk_parser: The upload chunk parser is used for DRF
+#    to handle incoming uploaded file chunks. For now this is just a
+#    placeholder that returns the input data unchanged. This test simply
+#    checks that the UploadChunkParser returns the input data unchanged.
+#
+# test_upload_chunk_parser_media_type: Check the UploadChunkParser is
+#    setup with the correct media type: 'application/offset+octet-stream'
+#
+class ParsersTestCase(TestCase):
+
+    def test_upload_chunk_parser(self):
+        '''The upload chunk parser is used for DRF to handle incoming
+           uploaded file chunks. For now this is just a placeholder that
+           returns the input data unchanged. This test simply checks that
+           the UploadChunkParser returns the input data unchanged.'''
+        parser = UploadChunkParser()
+        randbytes = os.urandom(256)
+        stream = BytesIO(randbytes)
+        stream.seek(0)
+        randbytes2 = parser.parse(stream)
+        self.assertEqual(randbytes, randbytes2)
+
+    def test_upload_chunk_parser_media_type(self):
+        '''Check the UploadChunkParser is setup with the correct media
+           type: 'application/offset+octet-stream' '''
+        parser = UploadChunkParser()
+        self.assertEqual(parser.media_type, 'application/offset+octet-stream')
\ No newline at end of file
diff --git a/tests/test_view_permissions.py b/tests/test_view_permissions.py
new file mode 100644
index 0000000..b997119
--- /dev/null
+++ b/tests/test_view_permissions.py
@@ -0,0 +1,11 @@
+from django.test import TestCase
+
+
+# test_view_permissions_applied: Test that a set of view permissions applied
+#     in the DJANGO_DRF_FILEPOND_PERMISSION_CLASSES setting is correctly
+#     applied via _import_permission_classes.
+#
+class ViewPermissionsTestCase(TestCase):
+
+    def test_view_permissions_applied(self):
+        pass
\ No newline at end of file