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..46618551
--- /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 --quiet-pull --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..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 = [
{
@@ -55,7 +54,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 +95,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