From 5131a72d9b971b869e7e6a1c978116acea982e6c Mon Sep 17 00:00:00 2001 From: Dhaval Gojiya Date: Sun, 14 Jun 2026 19:21:36 +0000 Subject: [PATCH 1/3] feat: add CI test pipeline and Elasticsearch 7 test setup --- .dockerignore | 2 +- .github/workflows/ci.yml | 81 ++++++ .gitignore | 10 +- Dockerfile | 17 -- Pipfile | 23 -- docker-compose.yml | 24 +- drf_haystack/query.py | 12 +- ez_setup.py | 494 ------------------------------------ pyproject.toml | 34 ++- setup.py | 2 +- tests/__init__.py | 54 ---- tests/{ => mockapp}/apps.py | 2 +- tests/mockapp/models.py | 9 +- tests/run_tests.py | 21 -- tests/settings.py | 12 +- tests/test_filters.py | 48 +++- tests/test_serializers.py | 4 +- tests/test_viewsets.py | 49 ---- tests/urls.py | 2 +- tox.ini | 33 --- 20 files changed, 197 insertions(+), 736 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 Dockerfile delete mode 100644 Pipfile delete mode 100644 ez_setup.py rename tests/{ => mockapp}/apps.py (79%) delete mode 100644 tests/run_tests.py delete mode 100644 tox.ini diff --git a/.dockerignore b/.dockerignore index 3e4e48b0..6c7b69a0 120000 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1 @@ -.gitignore \ No newline at end of file +.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f0f2e1db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + # renovate: datasource=pypi depName=uv + UV_VERSION: 0.11.21 + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}, Elasticsearch ${{ matrix.elastic-version }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + django-version: + - "4.2.*" + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + elastic-version: + - "7.17.29" + env: + UV_NO_DEV: 1 + ELASTICSEARCH_VERSION: ${{ matrix.elastic-version }} + PYTHONUNBUFFERED: 1 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Install GDAL + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gdal-bin + + - name: Start Elasticsearch + run: docker compose up -d --wait elasticsearch + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "${{ env.UV_VERSION }}" + python-version: "${{ matrix.python-version }}" + + - name: Setup project and install dependencies + run: uv sync + + # TODO: Remove this step, which relies on `setup.py`, once we + # migrate the build backend to hatch with `pyproject.toml` + - name: Install drf-haystack into the environment + run: uv pip install . + + # Use always latest patch version of Django from the matrix version pattern `major.minor.*`. + - name: Install matrix django + env: + DJANGO_VERSION: ${{ matrix.django-version }} + run: uv pip install -U "django==${DJANGO_VERSION}" + + - name: Run tests + run: uv run --no-sync manage.py test tests diff --git a/.gitignore b/.gitignore index 0db88740..d8b9ac39 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,10 @@ docs/_build/ # PyBuilder target/ -# Pipenv -.tool-versions -.idea +# uv +uv.lock +.venv/ + +# IDE +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 338d9052..00000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3-alpine@sha256:5a824eb82cc75361f98611f3cfc5091ea33f10a6ccea4d4ebdabbc523b9a1614 - -ENV DEBIAN_FRONTEND noninteractive -ENV PYTHONPATH /usr/local/src - -RUN apk add --no-cache --update \ - --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing \ - binutils build-base python3-dev gdal geos \ - && rm -rf /var/cache/apk/* - -COPY . /usr/local/src -WORKDIR /usr/local/src -RUN pip install -U pip setuptools \ - && pip install -r requirements.txt - -VOLUME /usr/local/src -CMD ["sh"] diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 5569a361..00000000 --- a/Pipfile +++ /dev/null @@ -1,23 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -django = ">=4.2,<5.2" -django-haystack = ">=2.8,<3.4" -djangorestframework = ">=3.12.0,<3.16" -python-dateutil = "*" - -[dev-packages] -coverage = "*" -sphinx = "*" -sphinx-rtd-theme = "*" -"urllib3" = "*" -geopy = "*" -tox = "*" -wheel = "*" -elasticsearch = ">=2.0.0,<=8.3.3" - -[requires] -python_version = "3" diff --git a/docker-compose.yml b/docker-compose.yml index c2d66c7f..785202da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,21 @@ -version: '2' +--- services: - elasticsearch2: - image: elasticsearch:2@sha256:41ed3a1a16b63de740767944d5405843db00e55058626c22838f23b413aa4a39 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-7.17.29} + environment: + - discovery.type=single-node ports: - - "9200:9200" - - "9300:9300" + - 9200:9200 + networks: + - es_search + healthcheck: + test: + - CMD-SHELL + - curl -fs http://localhost:9200/_cluster/health || exit 1 + interval: 10s + timeout: 5s + retries: 10 + +networks: + es_search: + driver: bridge diff --git a/drf_haystack/query.py b/drf_haystack/query.py index 27ccdb27..44c9ef15 100644 --- a/drf_haystack/query.py +++ b/drf_haystack/query.py @@ -4,6 +4,7 @@ from itertools import chain from dateutil import parser +from django.core.exceptions import ImproperlyConfigured from drf_haystack import constants from drf_haystack.utils import merge_dict @@ -263,15 +264,16 @@ def __init__(self, backend, view): ) try: - from haystack.utils.geo import D, Point + from django.contrib.gis.geos import Point + from django.contrib.gis.measure import D self.D = D self.Point = Point - except ImportError: + except ImproperlyConfigured: warnings.warn( - "Make sure you've installed the `libgeos` library. " - "Run `apt-get install libgeos` on debian based linux systems, " - "or `brew install geos` on OS X." + "Make sure you've installed the ``GDAL`` library (which also pulls in GEOS). " + "Run `apt install gdal-bin` on debian based linux systems, " + "or `brew install gdal` on OS X." ) raise diff --git a/ez_setup.py b/ez_setup.py deleted file mode 100644 index 8895b47f..00000000 --- a/ez_setup.py +++ /dev/null @@ -1,494 +0,0 @@ -#!python -"""Bootstrap distribute installation - -If you want to use setuptools in your package's setup.py, just include this -file in the same directory with it, and add this to the top of your setup.py:: - - from distribute_setup import use_setuptools - use_setuptools() - -If you want to require a specific version of setuptools, set a download -mirror, or use an alternate download directory, you can do so by supplying -the appropriate options to ``use_setuptools()``. - -This file can also be run as a script to install or upgrade setuptools. -""" - -import fnmatch -import os -import sys -import tarfile -import tempfile -import time -from distutils import log - -try: - from site import USER_SITE -except ImportError: - USER_SITE = None - -try: - import subprocess - - def _python_cmd(*args): - args = (sys.executable,) + args - return subprocess.call(args) == 0 - -except ImportError: - # will be used for python 2.3 - def _python_cmd(*args): - args = (sys.executable,) + args - # quoting arguments if windows - if sys.platform == "win32": - - def quote(arg): - if " " in arg: - return f'"{arg}"' - return arg - - args = [quote(arg) for arg in args] - return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 - - -DEFAULT_VERSION = "0.6.14" -DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" -SETUPTOOLS_FAKED_VERSION = "0.6c11" - -SETUPTOOLS_PKG_INFO = f"""\ -Metadata-Version: 1.0 -Name: setuptools -Version: {SETUPTOOLS_FAKED_VERSION} -Summary: xxxx -Home-page: xxx -Author: xxx -Author-email: xxx -License: xxx -Description: xxx -""" - - -def _install(tarball): - # extracting the tarball - tmpdir = tempfile.mkdtemp() - log.warn("Extracting in %s", tmpdir) - old_wd = os.getcwd() - try: - os.chdir(tmpdir) - tar = tarfile.open(tarball) - _extractall(tar) - tar.close() - - # going in the directory - subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) - os.chdir(subdir) - log.warn("Now working in %s", subdir) - - # installing - log.warn("Installing Distribute") - if not _python_cmd("setup.py", "install"): - log.warn("Something went wrong during the installation.") - log.warn("See the error message above.") - finally: - os.chdir(old_wd) - - -def _build_egg(egg, tarball, to_dir): - # extracting the tarball - tmpdir = tempfile.mkdtemp() - log.warn("Extracting in %s", tmpdir) - old_wd = os.getcwd() - try: - os.chdir(tmpdir) - tar = tarfile.open(tarball) - _extractall(tar) - tar.close() - - # going in the directory - subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) - os.chdir(subdir) - log.warn("Now working in %s", subdir) - - # building an egg - log.warn("Building a Distribute egg in %s", to_dir) - _python_cmd("setup.py", "-q", "bdist_egg", "--dist-dir", to_dir) - - finally: - os.chdir(old_wd) - # returning the result - log.warn(egg) - if not os.path.exists(egg): - raise OSError("Could not build the egg.") - - -def _do_download(version, download_base, to_dir, download_delay): - egg = os.path.join(to_dir, f"distribute-{version}-py{sys.version_info[0]}.{sys.version_info[1]}") - if not os.path.exists(egg): - tarball = download_setuptools(version, download_base, to_dir, download_delay) - _build_egg(egg, tarball, to_dir) - sys.path.insert(0, egg) - import setuptools - - setuptools.bootstrap_install_from = egg - - -def use_setuptools( - version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, download_delay=15, no_fake=True -): - # making sure we use the absolute path - to_dir = os.path.abspath(to_dir) - was_imported = "pkg_resources" in sys.modules or "setuptools" in sys.modules - try: - try: - import pkg_resources - - if not hasattr(pkg_resources, "_distribute"): - if not no_fake: - _fake_setuptools() - raise ImportError - except ImportError: - return _do_download(version, download_base, to_dir, download_delay) - try: - pkg_resources.require("distribute>=" + version) - return - except pkg_resources.VersionConflict: - e = sys.exc_info()[1] - if was_imported: - sys.stderr.write( - f"The required version of distribute (>={version}) is not available,\n" - "and can't be installed while this script is running. Please\n" - "install a more recent version first, using\n" - "'easy_install -U distribute'." - f"\n\n(Currently using {e.args[0]!r})\n" - ) - sys.exit(2) - else: - del pkg_resources, sys.modules["pkg_resources"] # reload ok - return _do_download(version, download_base, to_dir, download_delay) - except pkg_resources.DistributionNotFound: - return _do_download(version, download_base, to_dir, download_delay) - finally: - if not no_fake: - _create_fake_setuptools_pkg_info(to_dir) - - -def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, delay=15): - """Download distribute from a specified location and return its filename - - `version` should be a valid distribute version number that is available - as an egg for download under the `download_base` URL (which should end - with a '/'). `to_dir` is the directory where the egg will be downloaded. - `delay` is the number of seconds to pause before an actual download - attempt. - """ - # making sure we use the absolute path - to_dir = os.path.abspath(to_dir) - try: - from urllib.request import urlopen - except ImportError: - from urllib2 import urlopen - tgz_name = f"distribute-{version}.tar.gz" - url = download_base + tgz_name - saveto = os.path.join(to_dir, tgz_name) - src = dst = None - if not os.path.exists(saveto): # Avoid repeated downloads - try: - log.warn("Downloading %s", url) - src = urlopen(url) - # Read/write all in one block, so we don't create a corrupt file - # if the download is interrupted. - data = src.read() - dst = open(saveto, "wb") - dst.write(data) - finally: - if src: - src.close() - if dst: - dst.close() - return os.path.realpath(saveto) - - -def _no_sandbox(function): - def __no_sandbox(*args, **kw): - try: - from setuptools.sandbox import DirectorySandbox - - if not hasattr(DirectorySandbox, "_old"): - - def violation(*args): - pass - - DirectorySandbox._old = DirectorySandbox._violation - DirectorySandbox._violation = violation - patched = True - else: - patched = False - except ImportError: - patched = False - - try: - return function(*args, **kw) - finally: - if patched: - DirectorySandbox._violation = DirectorySandbox._old - del DirectorySandbox._old - - return __no_sandbox - - -def _patch_file(path, content): - """Will backup the file then patch it""" - existing_content = open(path).read() - if existing_content == content: - # already patched - log.warn("Already patched.") - return False - log.warn("Patching...") - _rename_path(path) - f = open(path, "w") - try: - f.write(content) - finally: - f.close() - return True - - -_patch_file = _no_sandbox(_patch_file) - - -def _same_content(path, content): - return open(path).read() == content - - -def _rename_path(path): - new_name = path + f".OLD.{time.time()}" - log.warn("Renaming %s into %s", path, new_name) - os.rename(path, new_name) - return new_name - - -def _remove_flat_installation(placeholder): - if not os.path.isdir(placeholder): - log.warn("Unkown installation at %s", placeholder) - return False - found = False - for file in os.listdir(placeholder): - if fnmatch.fnmatch(file, "setuptools*.egg-info"): - found = True - break - if not found: - log.warn("Could not locate setuptools*.egg-info") - return - - log.warn("Removing elements out of the way...") - pkg_info = os.path.join(placeholder, file) - if os.path.isdir(pkg_info): - patched = _patch_egg_dir(pkg_info) - else: - patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) - - if not patched: - log.warn("%s already patched.", pkg_info) - return False - # now let's move the files out of the way - for element in ("setuptools", "pkg_resources.py", "site.py"): - element = os.path.join(placeholder, element) - if os.path.exists(element): - _rename_path(element) - else: - log.warn("Could not find the %s element of the Setuptools distribution", element) - return True - - -_remove_flat_installation = _no_sandbox(_remove_flat_installation) - - -def _after_install(dist): - log.warn("After install bootstrap.") - placeholder = dist.get_command_obj("install").install_purelib - _create_fake_setuptools_pkg_info(placeholder) - - -def _create_fake_setuptools_pkg_info(placeholder): - if not placeholder or not os.path.exists(placeholder): - log.warn("Could not find the install location") - return - pyver = f"{sys.version_info[0]}.{sys.version_info[1]}" - setuptools_file = f"setuptools-{SETUPTOOLS_FAKED_VERSION}-py{pyver}.egg-info" - pkg_info = os.path.join(placeholder, setuptools_file) - if os.path.exists(pkg_info): - log.warn("%s already exists", pkg_info) - return - - log.warn("Creating %s", pkg_info) - f = open(pkg_info, "w") - try: - f.write(SETUPTOOLS_PKG_INFO) - finally: - f.close() - - pth_file = os.path.join(placeholder, "setuptools.pth") - log.warn("Creating %s", pth_file) - f = open(pth_file, "w") - try: - f.write(os.path.join(os.curdir, setuptools_file)) - finally: - f.close() - - -_create_fake_setuptools_pkg_info = _no_sandbox(_create_fake_setuptools_pkg_info) - - -def _patch_egg_dir(path): - # let's check if it's already patched - pkg_info = os.path.join(path, "EGG-INFO", "PKG-INFO") - if os.path.exists(pkg_info): - if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): - log.warn("%s already patched.", pkg_info) - return False - _rename_path(path) - os.mkdir(path) - os.mkdir(os.path.join(path, "EGG-INFO")) - pkg_info = os.path.join(path, "EGG-INFO", "PKG-INFO") - f = open(pkg_info, "w") - try: - f.write(SETUPTOOLS_PKG_INFO) - finally: - f.close() - return True - - -_patch_egg_dir = _no_sandbox(_patch_egg_dir) - - -def _before_install(): - log.warn("Before install bootstrap.") - _fake_setuptools() - - -def _under_prefix(location): - if "install" not in sys.argv: - return True - args = sys.argv[sys.argv.index("install") + 1 :] - for index, arg in enumerate(args): - for option in ("--root", "--prefix"): - if arg.startswith(f"{option}="): - top_dir = arg.split("root=")[-1] - return location.startswith(top_dir) - elif arg == option: - if len(args) > index: - top_dir = args[index + 1] - return location.startswith(top_dir) - if arg == "--user" and USER_SITE is not None: - return location.startswith(USER_SITE) - return True - - -def _fake_setuptools(): - log.warn("Scanning installed packages") - try: - import pkg_resources - except ImportError: - # we're cool - log.warn("Setuptools or Distribute does not seem to be installed.") - return - ws = pkg_resources.working_set - try: - setuptools_dist = ws.find(pkg_resources.Requirement.parse("setuptools", replacement=False)) - except TypeError: - # old distribute API - setuptools_dist = ws.find(pkg_resources.Requirement.parse("setuptools")) - - if setuptools_dist is None: - log.warn("No setuptools distribution found") - return - # detecting if it was already faked - setuptools_location = setuptools_dist.location - log.warn("Setuptools installation detected at %s", setuptools_location) - - # if --root or --preix was provided, and if - # setuptools is not located in them, we don't patch it - if not _under_prefix(setuptools_location): - log.warn("Not patching, --root or --prefix is installing Distribute in another location") - return - - # let's see if its an egg - if not setuptools_location.endswith(".egg"): - log.warn("Non-egg installation") - res = _remove_flat_installation(setuptools_location) - if not res: - return - else: - log.warn("Egg installation") - pkg_info = os.path.join(setuptools_location, "EGG-INFO", "PKG-INFO") - if os.path.exists(pkg_info) and _same_content(pkg_info, SETUPTOOLS_PKG_INFO): - log.warn("Already patched.") - return - log.warn("Patching...") - # let's create a fake egg replacing setuptools one - res = _patch_egg_dir(setuptools_location) - if not res: - return - log.warn("Patched done.") - _relaunch() - - -def _relaunch(): - log.warn("Relaunching...") - # we have to relaunch the process - # pip marker to avoid a relaunch bug - if sys.argv[:3] == ["-c", "install", "--single-version-externally-managed"]: - sys.argv[0] = "setup.py" - args = [sys.executable] + sys.argv - sys.exit(subprocess.call(args)) - - -def _extractall(self, path=".", members=None): - """Extract all members from the archive to the current working - directory and set owner, modification time and permissions on - directories afterwards. `path' specifies a different directory - to extract to. `members' is optional and must be a subset of the - list returned by getmembers(). - """ - import copy - import operator - from tarfile import ExtractError - - directories = [] - - if members is None: - members = self - - for tarinfo in members: - if tarinfo.isdir(): - # Extract directories with a safe mode. - directories.append(tarinfo) - tarinfo = copy.copy(tarinfo) - tarinfo.mode = 448 # decimal for oct 0700 - self.extract(tarinfo, path) - - # Reverse sort directories. - directories.sort(key=operator.attrgetter("name"), reverse=True) - - # Set correct owner, mtime and filemode on directories. - for tarinfo in directories: - dirpath = os.path.join(path, tarinfo.name) - try: - self.chown(tarinfo, dirpath) - self.utime(tarinfo, dirpath) - self.chmod(tarinfo, dirpath) - except ExtractError: - e = sys.exc_info()[1] - if self.errorlevel > 1: - raise - else: - self._dbg(1, f"tarfile: {e}") - - -def main(argv, version=DEFAULT_VERSION): - """Install or upgrade setuptools and EasyInstall""" - tarball = download_setuptools() - _install(tarball) - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/pyproject.toml b/pyproject.toml index e9ff3b17..4dcaadcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,13 @@ name = "drf-haystack" version = "1.9.1" description = "Makes Haystack play nice with Django REST Framework" readme = "README.md" -license = "MIT" +license = { text = "MIT" } authors = [ { name = "Dhaval Gojiya", email = "dhavalgojiya10@gmail.com" }, { name = "Rolf Håvard Blindheim", email = "rhblind@gmail.com" }, { name = "Ülgen Sarıkavak", email = "foss@ulgenwanders.net" }, ] -requires-python = ">=3.8,<3.11" +requires-python = ">=3.8,<3.15" classifiers = [ # The package is being transferred between organizations and has no working tests / CI. # Use at your own risk. @@ -23,14 +23,44 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP :: Indexing/Search", "Topic :: Software Development :: Libraries :: Python Modules", ] +dependencies = [ + "django>=4.2,<5.2", + "django-haystack>=2.8,<3.4", + "djangorestframework>=3.12,<3.16", + "python-dateutil", +] urls.Documentation = "https://drf-haystack.readthedocs.io" urls.Homepage = "https://github.com/ulgens/drf-haystack" urls.Issues = "https://github.com/ulgens/drf-haystack/issues" urls.Repository = "https://github.com/ulgens/drf-haystack.git" +[dependency-groups] +dev = [ + "prek==0.4.4", + "ruff==0.15.17", +] +test = [ + "coverage==7.6.1", + "elasticsearch==7.17.13", + "geopy==2.4.1", + "pytz==2026.2", +] +docs = [ + "sphinx==7.1.2", + "sphinx-rtd-theme==3.1.0", +] + +[tool.uv] +default-groups = [ "dev", "test" ] +fork-strategy = "fewest" + [tool.pyproject-fmt] indent = 4 keep_full_version = true diff --git a/setup.py b/setup.py index 6ab143c3..05317f87 100644 --- a/setup.py +++ b/setup.py @@ -57,5 +57,5 @@ def get_version(package): "Topic :: Internet :: WWW/HTTP :: Indexing/Search", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires=">=3.8, <3.11", + python_requires=">=3.8, <3.15", ) diff --git a/tests/__init__.py b/tests/__init__.py index 7d620dac..e69de29b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,54 +0,0 @@ -import os -from importlib.util import find_spec - -import django - -test_runner = None -old_config = None - -os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" - - -if hasattr(django, "setup"): - django.setup() - - -def _geospatial_support(): - return find_spec("geopy") and find_spec("haystack.utils.geo.Point") - - -geospatial_support = _geospatial_support() - - -def _restframework_version(): - import rest_framework - - return tuple(map(int, rest_framework.VERSION.split("."))) - - -restframework_version = _restframework_version() - - -def _elasticsearch_version(): - import elasticsearch - - return elasticsearch.VERSION - - -elasticsearch_version = _elasticsearch_version() - - -def setup(): - from django.test.runner import DiscoverRunner - - global test_runner - global old_config - - 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/apps.py b/tests/mockapp/apps.py similarity index 79% rename from tests/apps.py rename to tests/mockapp/apps.py index bf3db915..0b02d712 100644 --- a/tests/apps.py +++ b/tests/mockapp/apps.py @@ -2,5 +2,5 @@ class MockappConfig(AppConfig): - name = "mockapp" + name = "tests.mockapp" verbose_name = "Mock Application" diff --git a/tests/mockapp/models.py b/tests/mockapp/models.py index 1eaa6083..a587b9e7 100644 --- a/tests/mockapp/models.py +++ b/tests/mockapp/models.py @@ -2,6 +2,7 @@ from random import randint, randrange import pytz +from django.core.exceptions import ImproperlyConfigured from django.db import models @@ -37,11 +38,11 @@ def __str__(self): @property def coordinates(self): try: - from haystack.utils.geo import Point - except ImportError: - return None - else: + from django.contrib.gis.geos import Point + return Point(self.longitude, self.latitude, srid=4326) + except ImproperlyConfigured: + return None class MockPerson(models.Model): diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100644 index 6d051716..00000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python - - -import os -import sys -from pathlib import Path - -import django -from django.core.management import call_command - - -def start(argv=None): - sys.path.insert(0, str(Path(__file__).parent.parent)) - os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" - django.setup() - - call_command("test", sys.argv[1:]) - - -if __name__ == "__main__": - start(sys.argv) diff --git a/tests/settings.py b/tests/settings.py index 753dfe2f..ebda3003 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -55,7 +55,7 @@ HAYSTACK_CONNECTIONS = { "default": { - "ENGINE": "haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine", + "ENGINE": "haystack.backends.elasticsearch7_backend.Elasticsearch7SearchEngine", "URL": os.environ.get("ELASTICSEARCH_URL", "http://localhost:9200/"), "INDEX_NAME": "drf-haystack-test", "INCLUDE_SPELLING": True, @@ -96,13 +96,3 @@ }, }, } - -try: - import elasticsearch - - if (2,) <= elasticsearch.VERSION <= (3,): - HAYSTACK_CONNECTIONS["default"].update({ - "ENGINE": "haystack.backends.elasticsearch2_backend.Elasticsearch2SearchEngine" - }) -except ImportError: - del HAYSTACK_CONNECTIONS["default"] # This will intentionally cause everything to break! diff --git a/tests/test_filters.py b/tests/test_filters.py index 6e40cc14..0421cd6a 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -4,9 +4,10 @@ import json +import unittest from datetime import date, datetime, timedelta -from unittest import skipIf +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase from rest_framework import serializers, status from rest_framework.test import APIRequestFactory @@ -24,7 +25,6 @@ from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer from drf_haystack.viewsets import HaystackViewSet -from . import elasticsearch_version, geospatial_support from .constants import MOCKLOCATION_DATA_SET_SIZE, MOCKPERSON_DATA_SET_SIZE from .mixins import WarningTestCaseMixin from .mockapp.models import MockAllField, MockLocation, MockPerson @@ -33,6 +33,21 @@ factory = APIRequestFactory() +def gdal_is_available(): + """ + Return True if GDAL is installed. + + We can't import Point without the GDAL/GEOS libraries, so we just try it + and treat any failure as "not available". Used to skip the geo tests on + machines that don't have GDAL. + """ + try: + from django.contrib.gis.geos import Point # noqa: F401 + except ImproperlyConfigured: + return False + return True + + class HaystackFilterTestCase(TestCase): fixtures = ["mockperson", "mockallfield"] @@ -110,7 +125,10 @@ def test_filter_aliased_field(self): self.assertEqual(len(response.data), 1) def test_filter_aliased_field_with_lookup(self): - request = factory.get(path="/", data={"name__contains": "John McClane"}, content_type="application/json") + # `contains` builds a wildcard query (`*john* AND *mcclane*`). Since Elasticsearch 5.x + # dropped `lowercase_expanded_terms`, wildcard terms are matched case-sensitively against + # the lowercased indexed tokens, so the search value must be lowercase to match. + request = factory.get(path="/", data={"name__contains": "john mcclane"}, content_type="application/json") response = self.view1.as_view(actions={"get": "list"})(request) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) @@ -177,7 +195,9 @@ def test_filter_negated_field(self): self.assertEqual(len(response.data), 97) def test_filter_negated_field_with_lookup(self): - request = factory.get(path="/", data={"name__not__contains": "John McClane"}, content_type="application/json") + # Lowercase value required for the wildcard `contains` lookup on Elasticsearch 5.x+ + # (Same as `test_filter_aliased_field_with_lookup`) + request = factory.get(path="/", data={"name__not__contains": "john mcclane"}, content_type="application/json") response = self.view1.as_view(actions={"get": "list"})(request) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 99) @@ -260,7 +280,13 @@ def test_filter_autocomplete_single_field_OR(self): self.assertEqual(response.status_code, status.HTTP_200_OK) -@skipIf(not geospatial_support, "Skipped due to lack of GEO spatial features") +@unittest.skipUnless( + gdal_is_available(), + "To run these geo spatial tests, make sure you've installed the ``GDAL`` " + "library (which also pulls in GEOS). " + "Run `apt install gdal-bin` on debian based linux systems, " + "or `brew install gdal` on OS X.", +) class HaystackGEOSpatialFilterTestCase(TestCase): fixtures = ["mocklocation"] @@ -318,7 +344,9 @@ def setUp(self): class Serializer(HaystackSerializer): class Meta: index_classes = [MockPersonIndex] - fields = ["firstname", "lastname"] + # `text` (the document field) must be filterable so the query can + # target it; Elasticsearch only highlights matched fields. + fields = ["text", "firstname", "lastname"] class ViewSet(HaystackViewSet): index_models = [MockPerson] @@ -330,14 +358,16 @@ class ViewSet(HaystackViewSet): def tearDown(self): MockPersonIndex().clear() - @skipIf(not elasticsearch_version < (2,), "Highlighting is not yet supported for the Elasticsearch2 backend") def test_filter_highlighter_filter(self): - request = factory.get(path="/", data={"firstname": "jeremy"}, content_type="application/json") + # Elasticsearch only highlights fields that participated in the match, and + # the backend requests highlighting on the `text` document field, so the + # query has to target `text` for a `highlighted` fragment to be returned. + request = factory.get(path="/", data={"text": "jeremy"}, content_type="application/json") response = self.view.as_view(actions={"get": "list"})(request) response.render() for result in json.loads(response.content.decode()): self.assertTrue("highlighted" in result) - self.assertEqual(result["highlighted"], " ".join(("Jeremy", "{}\n".format(result["lastname"])))) + self.assertEqual(result["highlighted"], " ".join(("Jeremy", result["lastname"]))) class HaystackBoostFilterTestCase(TestCase): diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 977a23ee..62e35ab5 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -87,7 +87,7 @@ class Meta: router.register("search-person-mlt", viewset=SearchPersonMLTViewSet, basename="search-person-mlt") router.register("search-person-facet", viewset=SearchPersonFacetViewSet, basename="search-person-facet") -urlpatterns = [path(r"^", include(router.urls))] +urlpatterns = [path("", include(router.urls))] class HaystackSerializerTestCase(WarningTestCaseMixin, TestCase): @@ -640,7 +640,7 @@ def test_multi_serializer(self): self.assertEqual( json.loads(json.dumps(serializer.data)), [ - {"has_rabies": True, "text": "Zane", "name": "Zane", "species": "Dog"}, + {"has_rabies": True, "text": "Zane\n", "name": "Zane", "species": "Dog"}, { "text": "Zane Griffith\n", "firstname": "Zane", diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index e46f563d..32782973 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -4,7 +4,6 @@ import json -from unittest import skipIf from django.contrib.auth.models import User from django.test import TestCase @@ -19,7 +18,6 @@ from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer from drf_haystack.viewsets import HaystackViewSet -from . import restframework_version from .mockapp.models import MockPerson, MockPet from .mockapp.search_indexes import MockPersonIndex, MockPetIndex @@ -199,31 +197,6 @@ def test_viewset_get_queryset_with_IsAuthenticatedOrReadOnly_permission(self): # POST, PUT, PATCH and DELETE requests are not supported, so they will # raise an error. No need to test the permission. - @skipIf(not restframework_version < (3, 7), "Skipped due to fix in django-rest-framework > 3.6") - def test_viewset_get_queryset_with_DjangoModelPermissions_permission(self): - from rest_framework.permissions import DjangoModelPermissions - - setattr(self.view, "permission_classes", (DjangoModelPermissions,)) - - # The `DjangoModelPermissions` is not supported and should raise an - # AssertionError from rest_framework.permissions. - request = factory.get(path="/", data="", content_type="application/json") - try: - self.view.as_view(actions={"get": "list"})(request) - self.fail( - "Did not fail with AssertionError or AttributeError " - "when calling HaystackView with DjangoModelPermissions" - ) - except (AttributeError, AssertionError) as e: - if isinstance(e, AttributeError): - self.assertEqual(str(e), "'SearchQuerySet' object has no attribute 'model'") - else: - self.assertEqual( - str(e), - "Cannot apply DjangoModelPermissions on a view that does " - "not have `.model` or `.queryset` property.", - ) - def test_viewset_get_queryset_with_DjangoModelPermissionsOrAnonReadOnly_permission(self): from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly @@ -247,28 +220,6 @@ def test_viewset_get_queryset_with_DjangoModelPermissionsOrAnonReadOnly_permissi "not have `.model` or `.queryset` property.", ) - @skipIf(not restframework_version < (3, 7), "Skipped due to fix in django-rest-framework > 3.6") - def test_viewset_get_queryset_with_DjangoObjectPermissions_permission(self): - from rest_framework.permissions import DjangoObjectPermissions - - setattr(self.view, "permission_classes", (DjangoObjectPermissions,)) - - # The `DjangoObjectPermissions` is a subclass of `DjangoModelPermissions` and - # therefore unsupported. - request = factory.get(path="/", data="", content_type="application/json") - try: - self.view.as_view(actions={"get": "list"})(request) - self.fail("Did not fail with AssertionError when calling HaystackView with DjangoModelPermissions") - except (AttributeError, AssertionError) as e: - if isinstance(e, AttributeError): - self.assertEqual(str(e), "'SearchQuerySet' object has no attribute 'model'") - else: - self.assertEqual( - str(e), - "Cannot apply DjangoModelPermissions on a view that does " - "not have `.model` or `.queryset` property.", - ) - class PaginatedHaystackViewSetTestCase(TestCase): fixtures = ["mockperson"] diff --git a/tests/urls.py b/tests/urls.py index 95edb3f5..b175a7f2 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -7,4 +7,4 @@ router.register("search-person-facet", viewset=SearchPersonFacetViewSet, basename="search-person-facet") router.register("search-person-mlt", viewset=SearchPersonMLTViewSet, basename="search-person-mlt") -urlpatterns = [path(r"^", include(router.urls))] +urlpatterns = [path("", include(router.urls))] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 475c6413..00000000 --- a/tox.ini +++ /dev/null @@ -1,33 +0,0 @@ -[tox] -envlist = - docs - py{38,39,310,py}-django{4.2}-es{1.x,2.x} - - -[testenv] -commands = - coverage run {toxinidir}/tests/run_tests.py -deps = - python-dateutil - geopy==2.0.0 - coverage - requests - django4.2: Django>=4.2,<4.3 - es1.x: elasticsearch>=1,<2 - es2.x: elasticsearch>=2,<3 - # es5.x: elasticsearch>=5,<6 - # es7.x: elasticsearch>=7,<8 -setenv = - es1.x: VERSION_ES=>=1,<2 - es2.x: VERSION_ES=>=2,<3 - # es5.x: VERSION_ES=>=5,<6 - # es7.x: VERSION_ES=>=7,<8 - - -[testenv:docs] -changedir = docs -deps = - sphinx - sphinx-rtd-theme -commands = - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html From 9d26a374440803167ba2a469e9680a4311abc293 Mon Sep 17 00:00:00 2001 From: Dhaval Gojiya Date: Sun, 14 Jun 2026 19:22:31 +0000 Subject: [PATCH 2/3] fix(settings): Update middleware variable name and remove deprecated session authentication middleware --- tests/settings.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/settings.py b/tests/settings.py index ebda3003..b1f07603 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -25,15 +25,14 @@ "tests.mockapp", ) -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", -) +] TEMPLATES = [ { From 7ed6cf894581e7a1bcd939be0c535e2cfc5b71c2 Mon Sep 17 00:00:00 2001 From: Dhaval Gojiya Date: Mon, 15 Jun 2026 18:35:53 +0530 Subject: [PATCH 3/3] chore(ci): Add --quiet-pull flag to Docker Elasticsearch setup to limit terminal output --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0f2e1db..46618551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: sudo apt-get install -y --no-install-recommends gdal-bin - name: Start Elasticsearch - run: docker compose up -d --wait elasticsearch + run: docker compose up -d --quiet-pull --wait elasticsearch - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0