diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 8869d73..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,22 +0,0 @@ - - -- Django version: -- Algolia Django integration version: -- Algolia Client Version: #.#.# -- Language Version: - -### Description - - -### Steps To Reproduce diff --git a/.github/ISSUE_TEMPLATE/Bug_report.yml b/.github/ISSUE_TEMPLATE/Bug_report.yml new file mode 100644 index 0000000..da2ba62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.yml @@ -0,0 +1,73 @@ +name: Bug Report +description: File a bug report. +title: '[bug]: ' +labels: ['bug', 'triage'] +body: + - type: markdown + attributes: + value: | + ## Please help us help you! + + Before filing your issue, ask yourself: + - Is there an issue already opened for this bug? + - Can I reproduce it? + + If you are not sure about the origin of the issue, or if it impacts your customer experience, please contact [our support team](https://alg.li/support). + - type: textarea + attributes: + label: Description + description: A clear and concise description of what the bug is. + validations: + required: true + - type: dropdown + id: python version + attributes: + label: python version + description: What is the Python version you've reproduced the error with + options: + - 3.8 + - 3.9 + - 3.10 + - 3.11 + - 3.12 + - 3.13 + validations: + required: true + - type: dropdown + id: django version + attributes: + label: Django version + description: What is the Django version you've reproduced the error with + options: + - 4.0 + - 4.1 + - 4.2 + - 5.0 + - 5.1 + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: Write down the steps to reproduce the bug, please include any information that seems relevant for us to reproduce it properly + placeholder: | + 1. Use method `...` + 2. With parameters `...` + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + attributes: + label: Self-service + description: | + If you feel like you could contribute to this issue, please check the box below. This would tell us and other people looking for contributions that someone's working on it. + If you do check this box, please send a pull request within 7 days so we can still delegate this to someone else. + options: + - label: I'd be willing to fix this bug myself. + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 24da431..6894e86 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,22 +1,17 @@ -| Q | A -| ----------------- | ---------- -| Bug fix? | yes/no -| New feature? | yes/no -| BC breaks? | no -| Related Issue | Fix #... -| Need Doc update | yes/no +## 🧭 What and Why +🎟 Related Issue: -## Describe your change +### Changes included: -## What problem is this fixing? +## 🧪 Test diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 70632b3..cb72981 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,51 +17,68 @@ jobs: strategy: matrix: include: - - version: "3.5.4" - toxenv: py35-django20 - os: ubuntu-20.04 - - version: "3.6.7" - toxenv: py36-django32 - os: ubuntu-20.04 - - version: "3.7.5" - toxenv: py37-django32 - os: ubuntu-20.04 - - version: "3.8.15" - toxenv: py38-django32 - os: ubuntu-20.04 - - version: "3.9" - toxenv: py39-django30 - os: ubuntu-latest - - version: "3.9" - toxenv: py39-django31 - os: ubuntu-latest - - version: "3.9" - toxenv: py39-django32 - os: ubuntu-latest + # django 4.0 + - version: "3.8" + toxenv: py38-django40 + os: ubuntu-22.04 - version: "3.9" toxenv: py39-django40 - os: ubuntu-latest - - version: "3.9" - toxenv: py39-django41 - os: ubuntu-latest - - version: "3.9" - toxenv: py39-django42 - os: ubuntu-latest + os: ubuntu-22.04 - version: "3.10" toxenv: py310-django40 - os: ubuntu-latest + os: ubuntu-22.04 + # django 4.1 + - version: "3.8" + toxenv: py38-django41 + os: ubuntu-22.04 + - version: "3.9" + toxenv: py39-django41 + os: ubuntu-22.04 - version: "3.10" toxenv: py310-django41 - os: ubuntu-latest - - version: "3.10" - toxenv: py310-django42 - os: ubuntu-latest + os: ubuntu-22.04 - version: "3.11" toxenv: py311-django41 - os: ubuntu-latest + os: ubuntu-22.04 + # django 4.2 + - version: "3.8" + toxenv: py38-django42 + os: ubuntu-22.04 + - version: "3.9" + toxenv: py39-django42 + os: ubuntu-22.04 + - version: "3.10" + toxenv: py310-django42 + os: ubuntu-22.04 - version: "3.11" toxenv: py311-django42 - os: ubuntu-latest + os: ubuntu-22.04 + - version: "3.12" + toxenv: py312-django42 + os: ubuntu-22.04 + # django 5.0 + - version: "3.10" + toxenv: py310-django50 + os: ubuntu-22.04 + - version: "3.11" + toxenv: py311-django50 + os: ubuntu-22.04 + - version: "3.12" + toxenv: py312-django50 + os: ubuntu-22.04 + # django 5.1 + - version: "3.10" + toxenv: py310-django51 + os: ubuntu-22.04 + - version: "3.11" + toxenv: py311-django51 + os: ubuntu-22.04 + - version: "3.12" + toxenv: py312-django51 + os: ubuntu-22.04 + - version: "3.13" + toxenv: py313-django51 + os: ubuntu-22.04 steps: - uses: actions/checkout@v3 @@ -72,10 +89,10 @@ jobs: python-version: ${{ matrix.version }} - name: Install dependencies and run tests + timeout-minutes: 20 run: | python -m venv python-ci-run source python-ci-run/bin/activate - python -m pip install --upgrade pip - python -m pip install tox - python -m pip install -r requirements.txt + pip3 install --upgrade pip + pip3 install tox TOXENV=${{ matrix.toxenv }} ALGOLIA_APPLICATION_ID=${{ secrets.ALGOLIA_APPLICATION_ID }} ALGOLIA_API_KEY=${{ secrets.ALGOLIA_API_KEY }} tox diff --git a/DOCKER_README.md b/DOCKER_README.md index 27c94e5..8d1b0aa 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,41 +1,7 @@ -In this page you will find our recommended way of installing Docker on your machine. -This guide is made for OSX users. - -## Install Docker - -First install Docker using [Homebrew](https://brew.sh/) -``` -$ brew install docker -``` - -You can then install [Docker Desktop](https://docs.docker.com/get-docker/) if you wish, or use `docker-machine`. As we prefer the second option, we will only document this one. - -## Setup your Docker - -Install `docker-machine` -``` -$ brew install docker-machine -``` - -Then install [VirtualBox](https://www.virtualbox.org/) with [Homebrew Cask](https://github.com/Homebrew/homebrew-cask) to get a driver for your Docker machine -``` -$ brew cask install virtualbox -``` - -You may need to enter your password and authorize the application in your `System Settings` > `Security & Privacy`. - -Create now a new machine, set it up as default and connect your shell to it (here we use zsh. The commands should anyway be displayed in each steps' output) - -``` -$ docker-machine create --driver virtualbox default -$ docker-machine env default -$ eval "$(docker-machine env default)" -``` - -Now you're all setup to use our provided Docker image! - ## Build the image +> Make sure to have [Docker installed](https://docs.docker.com/engine/install/) + ```bash docker build -t algoliasearch-django . ``` @@ -53,8 +19,8 @@ docker run -it --rm --env ALGOLIA_APPLICATION_ID=XXXXXX \ However, we advise you to export them. That way, you can use [Docker's shorten syntax](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file) to set your variables. ```bash -export ALGOLIA_APPLICATION_ID=XXXXXX -export ALGOLIA_API_KEY=XXX +export ALGOLIA_APPLICATION_ID=XXXXXX +export ALGOLIA_API_KEY=XXX docker run -it --rm --env ALGOLIA_APPLICATION_ID --env ALGOLIA_API_KEY -v $PWD:/code -w /code algoliasearch-django bash ``` @@ -64,8 +30,8 @@ Once your container is running, any changes you make in your IDE are directly re To launch the tests, you can use this command ```bash -tox -e py36-django31 +tox -e py313-django51 ``` -If you'd like to sue an env other that `py36-django31`, run `tox --listenvs` to see the list of available envs. +If you'd like to sue an env other that `py313-django51`, run `tox --listenvs` to see the list of available envs. Feel free to contact us if you have any questions. diff --git a/Dockerfile b/Dockerfile index d9a39ee..97b1d66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.13-slim # Force the stdout and stderr streams to be unbuffered. # Ensure python output goes to your terminal @@ -7,6 +7,6 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /code COPY requirements.txt /code/ -RUN pip install -r requirements.txt +RUN pip3 install --upgrade pip && pip3 install -r requirements.txt COPY . /code/ diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 33c7143..ab0e2c7 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,5 +1,5 @@ ## `algolia/algoliasearch-django` maintainers -| Name | Email | -|-----------------|---------------------| -| Paul-Louis Nech | support@algolia.com | +| Name | Email | +|-----------------|------------------------| +| Algolia | https://alg.li/support | diff --git a/README.md b/README.md index abcc4e6..e430ff3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@

-

Documentation • Community Forum • @@ -26,55 +25,56 @@ You can find the full reference on [Algolia's website](https://www.algolia.com/doc/framework-integration/django/). - - 1. **[Setup](#setup)** - * [Introduction](#introduction) - * [Install](#install) - * [Setup](#setup) - * [Quick Start](#quick-start) + + - [Introduction](#introduction) + - [Install](#install) + - [Setup](#setup) + - [Quick Start](#quick-start) 1. **[Commands](#commands)** - * [Commands](#commands) + + - [Commands](#commands) 1. **[Search](#search)** - * [Search](#search) + + - [Search](#search) 1. **[Geo-Search](#geo-search)** - * [Geo-Search](#geo-search) + + - [Geo-Search](#geo-search) 1. **[Tags](#tags)** - * [Tags](#tags) + + - [Tags](#tags) 1. **[Options](#options)** - * [Custom objectID](#custom-codeobjectidcode) - * [Custom index name](#custom-index-name) - * [Field Preprocessing and Related objects](#field-preprocessing-and-related-objects) - * [Index settings](#index-settings) - * [Restrict indexing to a subset of your data](#restrict-indexing-to-a-subset-of-your-data) - * [Multiple indices per model](#multiple-indices-per-model) - * [Temporarily disable the auto-indexing](#temporarily-disable-the-auto-indexing) -1. **[Tests](#tests)** - * [Run Tests](#run-tests) + - [Custom objectID](#custom-codeobjectidcode) + - [Custom index name](#custom-index-name) + - [Field Preprocessing and Related objects](#field-preprocessing-and-related-objects) + - [Index settings](#index-settings) + - [Restrict indexing to a subset of your data](#restrict-indexing-to-a-subset-of-your-data) + - [Multiple indices per model](#multiple-indices-per-model) + - [Temporarily disable the auto-indexing](#temporarily-disable-the-auto-indexing) -1. **[Troubleshooting](#troubleshooting)** - * [Frequently asked questions](#frequently-asked-questions) +1. **[Tests](#tests)** + - [Run Tests](#run-tests) +1. **[Troubleshooting](#troubleshooting)** + - [Frequently asked questions](#frequently-asked-questions) # Setup - - ## Introduction This package lets you easily integrate the Algolia Search API to your [Django](https://www.djangoproject.com/) project. It's based on the [algoliasearch-client-python](https://github.com/algolia/algoliasearch-client-python) package. You might be interested in this sample Django application providing a typeahead.js based auto-completion and Google-like instant search: [algoliasearch-django-example](https://github.com/algolia/algoliasearch-django-example). -- Compatible with **Python 2.7** and **Python 3.4+**. -- Supports **Django 1.7+**, **2.x** and **3.x**. +- Compatible with **Python 3.8+**. +- Supports **Django 4.x** and **5.x**. ## Install @@ -95,10 +95,10 @@ ALGOLIA = { There are several optional settings: -* `INDEX_PREFIX`: prefix all indices. Use it to separate different applications, like `site1_Products` and `site2_Products`. -* `INDEX_SUFFIX`: suffix all indices. Use it to differentiate development and production environments, like `Location_dev` and `Location_prod`. -* `AUTO_INDEXING`: automatically synchronize the models with Algolia (default to **True**). -* `RAISE_EXCEPTIONS`: raise exceptions on network errors instead of logging them (default to **settings.DEBUG**). +- `INDEX_PREFIX`: prefix all indices. Use it to separate different applications, like `site1_Products` and `site2_Products`. +- `INDEX_SUFFIX`: suffix all indices. Use it to differentiate development and production environments, like `Location_dev` and `Location_prod`. +- `AUTO_INDEXING`: automatically synchronize the models with Algolia (default to **True**). +- `RAISE_EXCEPTIONS`: raise exceptions on network errors instead of logging them (default to **settings.DEBUG**). ## Quick Start @@ -134,25 +134,17 @@ class YourModelIndex(AlgoliaIndex): ``` - - # Commands - - ## Commands -* `python manage.py algolia_reindex`: reindex all the registered models. This command will first send all the record to a temporary index and then moves it. - * you can pass ``--model`` parameter to reindex a given model -* `python manage.py algolia_applysettings`: (re)apply the index settings. -* `python manage.py algolia_clearindex`: clear the index - - +- `python manage.py algolia_reindex`: reindex all the registered models. This command will first send all the record to a temporary index and then moves it. + - you can pass `--model` parameter to reindex a given model +- `python manage.py algolia_applysettings`: (re)apply the index settings. +- `python manage.py algolia_clearindex`: clear the index # Search - - ## Search We recommend using our [InstantSearch.js library](https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/) to build your search @@ -169,12 +161,8 @@ params = { "hitsPerPage": 5 } response = raw_search(Contact, "jim", params) ``` - - # Geo-Search - - ## Geo-Search Use the `geo_field` attribute to localize your record. `geo_field` should be a callable that returns a tuple (latitude, longitude). @@ -195,12 +183,8 @@ class ContactIndex(AlgoliaIndex): algoliasearch.register(Contact, ContactIndex) ``` - - # Tags - - ## Tags Use the `tags` attributes to add tags to your record. It can be a field or a callable. @@ -212,16 +196,12 @@ class ArticleIndex(AlgoliaIndex): At query time, specify `{ tagFilters: 'tagvalue' }` or `{ tagFilters: ['tagvalue1', 'tagvalue2'] }` as search parameters to restrict the result set to specific tags. - - # Options - - ## Custom `objectID` You can choose which field will be used as the `objectID `. The field should be unique and can - be a string or integer. By default, we use the `pk` field of the model. +be a string or integer. By default, we use the `pk` field of the model. ```python class ArticleIndex(AlgoliaIndex): @@ -279,8 +259,8 @@ class ContactIndex(AlgoliaIndex): - With this configuration, you can search for a `Contact` using its `Account` names - You can use the associated `account_ids` at search-time to fetch more data from your -model (you should **only proxy the fields relevant for search** to keep your records' size -as small as possible) + model (you should **only proxy the fields relevant for search** to keep your records' size + as small as possible) ## Index settings @@ -358,9 +338,9 @@ class MyModelMetaIndex(AlgoliaIndex): for index in self.indices: index.set_settings() - def clear_index(self): + def clear_objects(self): for index in self.indices: - index.clear_index() + index.clear_objects() def save_record(self, instance, update_fields=None, **kwargs): for index in self.indices: @@ -400,12 +380,8 @@ with disable_auto_indexing(MyModel): ``` - - # Tests - - ## Run Tests To run the tests, first find your Algolia application id and Admin API key (found on the Credentials page). @@ -414,8 +390,8 @@ To run the tests, first find your Algolia application id and Admin API key (foun ALGOLIA_APPLICATION_ID={APPLICATION_ID} ALGOLIA_API_KEY={ADMIN_API_KEY} tox ``` - To override settings for some tests, use the [settings method](https://docs.djangoproject.com/en/1.11/topics/testing/tools/#django.test.SimpleTestCase.settings): + ```python class OverrideSettingsTestCase(TestCase): def setUp(self): @@ -433,15 +409,12 @@ class OverrideSettingsTestCase(TestCase): # ... ``` - - # Troubleshooting # Use the Dockerfile + If you want to contribute to this project without installing all its dependencies, you can use our Docker image. Please check our [dedicated guide](DOCKER_README.md) to learn more. ## Frequently asked questions Encountering an issue? Before reaching out to support, we recommend heading to our [FAQ](https://www.algolia.com/doc/framework-integration/django/faq/) where you will find answers for the most common issues and gotchas with the package. - - diff --git a/algoliasearch_django/__init__.py b/algoliasearch_django/__init__.py index 95c7c7d..c4f1ef0 100644 --- a/algoliasearch_django/__init__.py +++ b/algoliasearch_django/__init__.py @@ -5,6 +5,7 @@ from django.utils.module_loading import autodiscover_modules +import logging from . import models from . import registration from . import settings @@ -30,13 +31,9 @@ delete_record = algolia_engine.delete_record update_records = algolia_engine.update_records raw_search = algolia_engine.raw_search -clear_index = algolia_engine.clear_index # TODO: deprecate clear_objects = algolia_engine.clear_objects reindex_all = algolia_engine.reindex_all -# Default log handler -import logging - class NullHandler(logging.Handler): def emit(self, record): @@ -44,9 +41,9 @@ def emit(self, record): def autodiscover(): - autodiscover_modules('index') + autodiscover_modules("index") -logging.getLogger(__name__.split('.')[0]).addHandler(NullHandler()) +logging.getLogger(__name__.split(".")[0]).addHandler(NullHandler()) -default_app_config = 'algoliasearch_django.apps.AlgoliaConfig' +default_app_config = "algoliasearch_django.apps.AlgoliaConfig" diff --git a/algoliasearch_django/apps.py b/algoliasearch_django/apps.py index a614999..26ae6c3 100644 --- a/algoliasearch_django/apps.py +++ b/algoliasearch_django/apps.py @@ -4,7 +4,7 @@ class AlgoliaConfig(AppConfig): """Simple AppConfig which does not do automatic discovery.""" - name = 'algoliasearch_django' + name = "algoliasearch_django" def ready(self): super(AlgoliaConfig, self).ready() diff --git a/algoliasearch_django/decorators.py b/algoliasearch_django/decorators.py index 23c4eae..9b9adea 100644 --- a/algoliasearch_django/decorators.py +++ b/algoliasearch_django/decorators.py @@ -1,9 +1,5 @@ -try: - # ContextDecorator was introduced in Python 3.2 - from contextlib import ContextDecorator -except ImportError: - ContextDecorator = None -from functools import WRAPPER_ASSIGNMENTS, wraps +from contextlib import ContextDecorator +from functools import WRAPPER_ASSIGNMENTS from django.db.models.signals import post_save, pre_delete @@ -19,21 +15,6 @@ def available_attrs(fn): return WRAPPER_ASSIGNMENTS -if ContextDecorator is None: - # ContextDecorator was introduced in Python 3.2 - # See https://docs.python.org/3/library/contextlib.html#contextlib.ContextDecorator - class ContextDecorator: - """ - A base class that enables a context manager to also be used as a decorator. - """ - def __call__(self, func): - @wraps(func, assigned=available_attrs(func)) - def inner(*args, **kwargs): - with self: - return func(*args, **kwargs) - return inner - - def register(model): """ Register the given model class and wrapped AlgoliaIndex class with the Algolia engine: @@ -47,11 +28,12 @@ class AuthorIndex(AlgoliaIndex): def _algolia_engine_wrapper(index_class): if not issubclass(index_class, AlgoliaIndex): - raise ValueError('Wrapped class must subclass AlgoliaIndex.') + raise ValueError("Wrapped class must subclass AlgoliaIndex.") register(model, index_class) return index_class + return _algolia_engine_wrapper @@ -71,26 +53,26 @@ def __init__(self, model=None): if model is not None: self.models = [model] else: - self.models = algolia_engine._AlgoliaEngine__registered_models + self.models = algolia_engine._AlgoliaEngine__registered_models # pyright: ignore def __enter__(self): for model in self.models: post_save.disconnect( - algolia_engine._AlgoliaEngine__post_save_receiver, - sender=model + algolia_engine._AlgoliaEngine__post_save_receiver, # pyright: ignore + sender=model, ) pre_delete.disconnect( - algolia_engine._AlgoliaEngine__pre_delete_receiver, - sender=model + algolia_engine._AlgoliaEngine__pre_delete_receiver, # pyright: ignore + sender=model, ) def __exit__(self, exc_type, exc_value, traceback): for model in self.models: post_save.connect( - algolia_engine._AlgoliaEngine__post_save_receiver, - sender=model + algolia_engine._AlgoliaEngine__post_save_receiver, # pyright: ignore + sender=model, ) pre_delete.connect( - algolia_engine._AlgoliaEngine__pre_delete_receiver, - sender=model + algolia_engine._AlgoliaEngine__pre_delete_receiver, # pyright: ignore + sender=model, ) diff --git a/algoliasearch_django/management/commands/algolia_applysettings.py b/algoliasearch_django/management/commands/algolia_applysettings.py index cb034e4..69df31b 100644 --- a/algoliasearch_django/management/commands/algolia_applysettings.py +++ b/algoliasearch_django/management/commands/algolia_applysettings.py @@ -5,18 +5,17 @@ class Command(BaseCommand): - help = 'Apply index settings.' + help = "Apply index settings." def add_arguments(self, parser): - parser.add_argument('--model', nargs='+', type=str) + parser.add_argument("--model", nargs="+", type=str) def handle(self, *args, **options): """Run the management command.""" - self.stdout.write('Apply settings to index:') + self.stdout.write("Apply settings to index:") for model in get_registered_model(): - if options.get('model', None) and not (model.__name__ in - options['model']): + if options.get("model", None) and model.__name__ not in options["model"]: continue get_adapter(model).set_settings() - self.stdout.write('\t* {}'.format(model.__name__)) + self.stdout.write("\t* {}".format(model.__name__)) diff --git a/algoliasearch_django/management/commands/algolia_clearindex.py b/algoliasearch_django/management/commands/algolia_clearindex.py index a2c4ade..3e5dff0 100644 --- a/algoliasearch_django/management/commands/algolia_clearindex.py +++ b/algoliasearch_django/management/commands/algolia_clearindex.py @@ -5,18 +5,17 @@ class Command(BaseCommand): - help = 'Clear index.' + help = "Clear index." def add_arguments(self, parser): - parser.add_argument('--model', nargs='+', type=str) + parser.add_argument("--model", nargs="+", type=str) def handle(self, *args, **options): """Run the management command.""" - self.stdout.write('Clear index:') + self.stdout.write("Clear index:") for model in get_registered_model(): - if options.get('model', None) and not (model.__name__ in - options['model']): + if options.get("model", None) and model.__name__ not in options["model"]: continue clear_objects(model) - self.stdout.write('\t* {}'.format(model.__name__)) + self.stdout.write("\t* {}".format(model.__name__)) diff --git a/algoliasearch_django/management/commands/algolia_reindex.py b/algoliasearch_django/management/commands/algolia_reindex.py index ce9b9da..bd1ad50 100644 --- a/algoliasearch_django/management/commands/algolia_reindex.py +++ b/algoliasearch_django/management/commands/algolia_reindex.py @@ -5,25 +5,24 @@ class Command(BaseCommand): - help = 'Reindex all models to Algolia' + help = "Reindex all models to Algolia" def add_arguments(self, parser): - parser.add_argument('--batchsize', nargs='?', default=1000, type=int) - parser.add_argument('--model', nargs='+', type=str) + parser.add_argument("--batchsize", nargs="?", default=1000, type=int) + parser.add_argument("--model", nargs="+", type=str) def handle(self, *args, **options): """Run the management command.""" - batch_size = options.get('batchsize', None) + batch_size = options.get("batchsize", None) if not batch_size: # py34-django18: batchsize is set to None if the user don't set # the value, instead of not be present in the dict batch_size = 1000 - self.stdout.write('The following models were reindexed:') + self.stdout.write("The following models were reindexed:") for model in get_registered_model(): - if options.get('model', None) and not (model.__name__ in - options['model']): + if options.get("model", None) and model.__name__ not in options["model"]: continue counts = reindex_all(model, batch_size=batch_size) - self.stdout.write('\t* {} --> {}'.format(model.__name__, counts)) + self.stdout.write("\t* {} --> {}".format(model.__name__, counts)) diff --git a/algoliasearch_django/models.py b/algoliasearch_django/models.py index bb3730e..2d12355 100644 --- a/algoliasearch_django/models.py +++ b/algoliasearch_django/models.py @@ -4,9 +4,12 @@ from functools import partial from itertools import chain import logging +from typing import Callable, Iterable, Optional -import sys -from algoliasearch.exceptions import AlgoliaException +from algoliasearch.http.exceptions import AlgoliaException +from algoliasearch.search.models.operation_index_params import OperationIndexParams +from algoliasearch.search.models.operation_type import OperationType +from algoliasearch.search.models.search_params_object import SearchParamsObject from django.db.models.query_utils import DeferredAttribute from .settings import DEBUG @@ -26,8 +29,7 @@ def check_and_get_attr(model, name): else: return get_model_attr(name) except AttributeError: - raise AlgoliaIndexError( - '{} is not an attribute of {}'.format(name, model)) + raise AlgoliaIndexError("{} is not an attribute of {}".format(name, model)) def get_model_attr(name): @@ -43,7 +45,7 @@ class AlgoliaIndex(object): # Use to specify a custom field that will be used for the objectID. # This field should be unique. - custom_objectID = 'pk' + custom_objectID = "pk" # Use to specify the fields that should be included in the index. fields = () @@ -56,7 +58,7 @@ class AlgoliaIndex(object): tags = None # Use to specify the index to target on Algolia. - index_name = None + index_name: Optional[str] = None # Use to specify the settings of the index. settings = None @@ -71,35 +73,52 @@ class AlgoliaIndex(object): # Name of the attribute to check on instances if should_index is not a callable _should_index_is_method = False + get_queryset: Optional[Callable[[], Iterable]] = None + def __init__(self, model, client, settings): """Initializes the index.""" - self.__init_index(client, model, settings) + if not self.index_name: + self.index_name = model.__name__ + + tmp_index_name = "{index_name}_tmp".format(index_name=self.index_name) + + if "INDEX_PREFIX" in settings: + self.index_name = settings["INDEX_PREFIX"] + "_" + self.index_name + tmp_index_name = "{index_prefix}_{tmp_index_name}".format( + tmp_index_name=tmp_index_name, index_prefix=settings["INDEX_PREFIX"] + ) + if "INDEX_SUFFIX" in settings: + self.index_name += "_" + settings["INDEX_SUFFIX"] + tmp_index_name = "{tmp_index_name}_{index_suffix}".format( + tmp_index_name=tmp_index_name, index_suffix=settings["INDEX_SUFFIX"] + ) + + self.tmp_index_name = tmp_index_name self.model = model self.__client = client self.__named_fields = {} self.__translate_fields = {} - if self.settings is None: # Only set settings if the actual index class does not define some + if ( + self.settings is None + ): # Only set settings if the actual index class does not define some self.settings = {} - try: - all_model_fields = [f.name for f in model._meta.get_fields() if not f.is_relation] - except AttributeError: # get_fields requires Django >= 1.8 - all_model_fields = [f.name for f in model._meta.local_fields] + all_model_fields = [ + f.name for f in model._meta.get_fields() if not f.is_relation + ] if isinstance(self.fields, str): self.fields = (self.fields,) elif isinstance(self.fields, (list, tuple, set)): self.fields = tuple(self.fields) else: - raise AlgoliaIndexError('Fields must be a str, list, tuple or set') + raise AlgoliaIndexError("Fields must be a str, list, tuple or set") # Check fields for field in self.fields: - # unicode is a type in python < 3.0, which we need to support (e.g. dev uses unicode_literals) - # noinspection PyUnresolvedReferences - if sys.version_info < (3, 0) and isinstance(field, unicode) or isinstance(field, str): + if isinstance(field, str): attr = field name = field elif isinstance(field, (list, tuple)) and len(field) == 2: @@ -107,7 +126,8 @@ def __init__(self, model, client, settings): name = field[1] else: raise AlgoliaIndexError( - 'Invalid fields syntax: {} (type: {})'.format(field, type(field))) + "Invalid fields syntax: {} (type: {})".format(field, type(field)) + ) self.__translate_fields[attr] = name if attr in all_model_fields: @@ -118,21 +138,25 @@ def __init__(self, model, client, settings): # If no fields are specified, index all the fields of the model if not self.fields: self.fields = set(all_model_fields) - for elt in ('pk', 'id', 'objectID'): + for elt in ("pk", "id", "objectID"): try: self.fields.remove(elt) except KeyError: continue self.__translate_fields = dict(zip(self.fields, self.fields)) - self.__named_fields = dict(zip(self.fields, map(get_model_attr, - self.fields))) + self.__named_fields = dict( + zip(self.fields, map(get_model_attr, self.fields)) + ) # Check custom_objectID - if self.custom_objectID in chain(['pk'], all_model_fields) or hasattr(model, self.custom_objectID): + if self.custom_objectID in chain(["pk"], all_model_fields) or hasattr( + model, self.custom_objectID + ): self.objectID = get_model_attr(self.custom_objectID) else: - raise AlgoliaIndexError('{} is not a model field of {}'.format( - self.custom_objectID, model)) + raise AlgoliaIndexError( + "{} is not a model field of {}".format(self.custom_objectID, model) + ) # Check tags if self.tags: @@ -149,47 +173,28 @@ def __init__(self, model, client, settings): if self.should_index: if hasattr(model, self.should_index): attr = getattr(model, self.should_index) - if type(attr) is not bool: # if attr is a bool, we keep attr=name to getattr on instance + if ( + type(attr) is not bool + ): # if attr is a bool, we keep attr=name to getattr on instance self.should_index = attr if callable(self.should_index): self._should_index_is_method = True else: try: model._meta.get_field_by_name(self.should_index) - except: - raise AlgoliaIndexError('{} is not an attribute nor a field of {}.'.format( - self.should_index, model)) - - def __init_index(self, client, model, settings): - if not self.index_name: - self.index_name = model.__name__ - - tmp_index_name = '{index_name}_tmp'.format(index_name=self.index_name) - - if 'INDEX_PREFIX' in settings: - self.index_name = settings['INDEX_PREFIX'] + '_' + self.index_name - tmp_index_name = '{index_prefix}_{tmp_index_name}'.format( - tmp_index_name=tmp_index_name, - index_prefix=settings['INDEX_PREFIX'] - ) - if 'INDEX_SUFFIX' in settings: - self.index_name += '_' + settings['INDEX_SUFFIX'] - tmp_index_name = '{tmp_index_name}_{index_suffix}'.format( - tmp_index_name=tmp_index_name, - index_suffix=settings['INDEX_SUFFIX'] - ) - - self.tmp_index_name = tmp_index_name - - self.__index = client.init_index(self.index_name) - self.__tmp_index = client.init_index(self.tmp_index_name) + except Exception: + raise AlgoliaIndexError( + "{} is not an attribute nor a field of {}.".format( + self.should_index, model + ) + ) @staticmethod def _validate_geolocation(geolocation): """ Make sure we have the proper geolocation format. """ - if set(geolocation) != {'lat', 'lng'}: + if set(geolocation) != {"lat", "lng"}: raise AlgoliaIndexError( 'Invalid geolocation format, requires "lat" and "lng" keys only got {}'.format( geolocation @@ -204,7 +209,7 @@ def get_raw_record(self, instance, update_fields=None): the objectID and the given fields. Also, `_geoloc` and `_tags` will not be included. """ - tmp = {'objectID': self.objectID(instance)} + tmp = {"objectID": self.objectID(instance)} if update_fields: if isinstance(update_fields, str): @@ -222,21 +227,21 @@ def get_raw_record(self, instance, update_fields=None): loc = self.geo_field(instance) if isinstance(loc, tuple): - tmp['_geoloc'] = {'lat': loc[0], 'lng': loc[1]} + tmp["_geoloc"] = {"lat": loc[0], "lng": loc[1]} elif isinstance(loc, dict): self._validate_geolocation(loc) - tmp['_geoloc'] = loc + tmp["_geoloc"] = loc elif isinstance(loc, list): [self._validate_geolocation(geo) for geo in loc] - tmp['_geoloc'] = loc + tmp["_geoloc"] = loc if self.tags: if callable(self.tags): - tmp['_tags'] = self.tags(instance) - if not isinstance(tmp['_tags'], list): - tmp['_tags'] = list(tmp['_tags']) + tmp["_tags"] = self.tags(instance) + if not isinstance(tmp["_tags"], list): + tmp["_tags"] = list(tmp["_tags"]) # pyright: ignore - logger.debug('BUILD %s FROM %s', tmp['objectID'], self.model) + logger.debug("BUILD %s FROM %s", tmp["objectID"], self.model) return tmp def _has_should_index(self): @@ -252,20 +257,23 @@ def _should_index(self, instance): def _should_really_index(self, instance): """Return True if according to should_index the object should be indexed.""" + if self.should_index is None: + raise AlgoliaIndexError("{} should be defined.".format(self.should_index)) + if self._should_index_is_method: is_method = inspect.ismethod(self.should_index) try: - count_args = len(inspect.signature(self.should_index).parameters) + count_args = len(inspect.signature(self.should_index).parameters) # pyright: ignore -- should_index_is_method except AttributeError: # noinspection PyDeprecation - count_args = len(inspect.getargspec(self.should_index).args) + count_args = len(inspect.getfullargspec(self.should_index).args) if is_method or count_args == 1: # bound method, call with instance - return self.should_index(instance) + return self.should_index(instance) # pyright: ignore -- should_index_is_method else: # unbound method, simply call without arguments - return self.should_index() + return self.should_index() # pyright: ignore -- should_index_is_method else: # property/attribute/Field, evaluate as bool attr_type = type(self.should_index) @@ -276,11 +284,16 @@ def _should_really_index(self, instance): elif attr_type is property: attr_value = self.should_index.__get__(instance) else: - raise AlgoliaIndexError('{} should be a boolean attribute or a method that returns a boolean.'.format( - self.should_index)) + raise AlgoliaIndexError( + "{} should be a boolean attribute or a method that returns a boolean.".format( + self.should_index + ) + ) if type(attr_value) is not bool: - raise AlgoliaIndexError("%s's should_index (%s) should be a boolean" % ( - instance.__class__.__name__, self.should_index)) + raise AlgoliaIndexError( + "%s's should_index (%s) should be a boolean" + % (instance.__class__.__name__, self.should_index) + ) return attr_value def save_record(self, instance, update_fields=None, **kwargs): @@ -299,35 +312,40 @@ def save_record(self, instance, update_fields=None, **kwargs): self.delete_record(instance) return + obj = {} try: if update_fields: - obj = self.get_raw_record(instance, - update_fields=update_fields) - result = self.__index.partial_update_object(obj) + obj = self.get_raw_record(instance, update_fields=update_fields) + self.__client.partial_update_objects( + index_name=self.index_name, objects=[obj], wait_for_tasks=True + ) else: obj = self.get_raw_record(instance) - result = self.__index.save_object(obj) - logger.info('SAVE %s FROM %s', obj['objectID'], self.model) - return result + self.__client.save_objects( + index_name=self.index_name, objects=[obj], wait_for_tasks=True + ) + logger.info("SAVE %s FROM %s", obj["objectID"], self.model) except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('%s FROM %s NOT SAVED: %s', obj['objectID'], - self.model, e) + logger.warning( + "%s FROM %s NOT SAVED: %s", obj["objectID"], self.model, e + ) def delete_record(self, instance): """Deletes the record.""" objectID = self.objectID(instance) try: - self.__index.delete_object(objectID) - logger.info('DELETE %s FROM %s', objectID, self.model) + self.__client.delete_objects( + index_name=self.index_name, object_ids=[objectID], wait_for_tasks=True + ) + logger.info("DELETE %s FROM %s", objectID, self.model) except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('%s FROM %s NOT DELETED: %s', objectID, - self.model, e) + logger.warning("%s FROM %s NOT DELETED: %s", objectID, self.model, e) def update_records(self, qs, batch_size=1000, **kwargs): """ @@ -350,42 +368,43 @@ def update_records(self, qs, batch_size=1000, **kwargs): batch = [] objectsIDs = qs.only(self.custom_objectID).values_list( - self.custom_objectID, flat=True) + self.custom_objectID, flat=True + ) for elt in objectsIDs: - tmp['objectID'] = elt + tmp["objectID"] = elt batch.append(dict(tmp)) - if len(batch) >= batch_size: - self.__index.partial_update_objects(batch) - batch = [] - + # TODO: pass batch_size to partial_update_objects if len(batch) > 0: - self.__index.partial_update_objects(batch) + self.__client.partial_update_objects( + index_name=self.index_name, objects=batch, wait_for_tasks=True + ) - def raw_search(self, query='', params=None): + def raw_search(self, query="", params=None): """Performs a search query and returns the parsed JSON.""" if params is None: - params = {} + params = SearchParamsObject().to_dict() + + params["query"] = query try: - return self.__index.search(query, params) + return self.__client.search_single_index(self.index_name, params).to_dict() except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('ERROR DURING SEARCH ON %s: %s', self.index_name, e) + logger.warning("ERROR DURING SEARCH ON %s: %s", self.index_name, e) - def get_settings(self): + def get_settings(self) -> Optional[dict]: """Returns the settings of the index.""" try: - logger.info('GET SETTINGS ON %s', self.index_name) - return self.__index.get_settings() + logger.info("GET SETTINGS ON %s", self.index_name) + return self.__client.get_settings(self.index_name).to_dict() except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('ERROR DURING GET_SETTINGS ON %s: %s', - self.model, e) + logger.warning("ERROR DURING GET_SETTINGS ON %s: %s", self.model, e) def set_settings(self): """Applies the settings to the index.""" @@ -393,44 +412,43 @@ def set_settings(self): return try: - self.__index.set_settings(self.settings) - logger.info('APPLY SETTINGS ON %s', self.index_name) + _resp = self.__client.set_settings(self.index_name, self.settings) + self.__client.wait_for_task(self.index_name, _resp.task_id) + logger.info("APPLY SETTINGS ON %s", self.index_name) except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('SETTINGS NOT APPLIED ON %s: %s', - self.model, e) + logger.warning("SETTINGS NOT APPLIED ON %s: %s", self.model, e) def clear_objects(self): """Clears all objects of an index.""" try: - self.__index.clear_objects() - logger.info('CLEAR INDEX %s', self.index_name) + _resp = self.__client.clear_objects(self.index_name) + self.__client.wait_for_task(self.index_name, _resp.task_id) + logger.info("CLEAR INDEX %s", self.index_name) except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('%s NOT CLEARED: %s', self.model, e) - - def clear_index(self): - # TODO: add deprecated warning - self.clear_objects() + logger.warning("%s NOT CLEARED: %s", self.model, e) def wait_task(self, task_id): try: - self.__index.wait_task(task_id) - logger.info('WAIT TASK %s', self.index_name) + self.__client.wait_for_task(self.index_name, task_id) + logger.info("WAIT TASK %s", self.index_name) except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('%s NOT WAIT: %s', self.model, e) + logger.warning("%s NOT WAIT: %s", self.model, e) def delete(self): - self.__index.delete() - if self.__tmp_index: - self.__tmp_index.delete() + _resp = self.__client.delete_index(self.index_name) + self.__client.wait_for_task(self.index_name, _resp.task_id) + if self.tmp_index_name: + _resp = self.__client.delete_index(self.tmp_index_name) + self.__client.wait_for_task(self.tmp_index_name, _resp.task_id) def reindex_all(self, batch_size=1000): """ @@ -445,51 +463,60 @@ def reindex_all(self, batch_size=1000): try: if not self.settings: self.settings = self.get_settings() - logger.debug('Got settings for index %s: %s', self.index_name, self.settings) + logger.debug( + "Got settings for index %s: %s", self.index_name, self.settings + ) else: - logger.debug("index %s already has settings: %s", self.index_name, self.settings) + logger.debug( + "index %s already has settings: %s", self.index_name, self.settings + ) except AlgoliaException as e: if any("Index does not exist" in arg for arg in e.args): pass # Expected, let's clear and recreate from scratch else: raise e # Unexpected error while getting settings try: + should_keep_replicas = False + replicas = None + if self.settings: - replicas = self.settings.get('replicas', None) - slaves = self.settings.get('slaves', None) + replicas = self.settings.get("replicas", None) should_keep_replicas = replicas is not None - should_keep_slaves = slaves is not None if should_keep_replicas: - self.settings['replicas'] = [] + self.settings["replicas"] = [] logger.debug("REMOVE REPLICAS FROM SETTINGS") - if should_keep_slaves: - self.settings['slaves'] = [] - logger.debug("REMOVE SLAVES FROM SETTINGS") - self.__tmp_index.set_settings(self.settings).wait() - logger.debug('APPLY SETTINGS ON %s_tmp', self.index_name) + _resp = self.__client.set_settings(self.tmp_index_name, self.settings) + self.__client.wait_for_task(self.tmp_index_name, _resp.task_id) + logger.debug("APPLY SETTINGS ON %s_tmp", self.index_name) + rules = [] - synonyms = [] - for r in self.__index.browse_rules(): - rules.append(r) - for s in self.__index.browse_synonyms(): - synonyms.append(s) + self.__client.browse_rules( + self.index_name, lambda _resp: rules.extend(_resp.hits) + ) if len(rules): - logger.debug('Got rules for index %s: %s', self.index_name, rules) + logger.debug("Got rules for index %s: %s", self.index_name, rules) should_keep_rules = True + + synonyms = [] + self.__client.browse_synonyms( + self.index_name, lambda _resp: synonyms.extend(_resp.hits) + ) if len(synonyms): - logger.debug('Got synonyms for index %s: %s', self.index_name, rules) + logger.debug("Got synonyms for index %s: %s", self.index_name, rules) should_keep_synonyms = True - self.__tmp_index.clear_objects() - logger.debug('CLEAR INDEX %s_tmp', self.index_name) + _resp = self.__client.clear_objects(self.tmp_index_name) + self.__client.wait_for_task(self.tmp_index_name, _resp.task_id) + logger.debug("CLEAR INDEX %s", self.tmp_index_name) counts = 0 batch = [] + qs = [] - if hasattr(self, 'get_queryset'): + if hasattr(self, "get_queryset") and callable(self.get_queryset): qs = self.get_queryset() else: qs = self.model.objects.all() @@ -500,42 +527,56 @@ def reindex_all(self, batch_size=1000): batch.append(self.get_raw_record(instance)) if len(batch) >= batch_size: - self.__tmp_index.save_objects(batch) - logger.info('SAVE %d OBJECTS TO %s_tmp', len(batch), - self.index_name) + self.__client.save_objects( + index_name=self.tmp_index_name, + objects=batch, + wait_for_tasks=True, + ) + logger.info( + "SAVE %d OBJECTS TO %s", len(batch), self.tmp_index_name + ) batch = [] counts += 1 if len(batch) > 0: - self.__tmp_index.save_objects(batch) - logger.info('SAVE %d OBJECTS TO %s_tmp', len(batch), - self.index_name) - - self.__client.move_index(self.tmp_index_name, - self.index_name) - logger.info('MOVE INDEX %s_tmp TO %s', self.index_name, - self.index_name) + self.__client.save_objects( + index_name=self.tmp_index_name, objects=batch, wait_for_tasks=True + ) + logger.info("SAVE %d OBJECTS TO %s", len(batch), self.tmp_index_name) + + _resp = self.__client.operation_index( + self.tmp_index_name, + OperationIndexParams( + operation=OperationType.MOVE, + destination=self.index_name, # pyright: ignore + ), + ) + self.__client.wait_for_task(self.tmp_index_name, _resp.task_id) + logger.info("MOVE INDEX %s TO %s", self.tmp_index_name, self.index_name) if self.settings: if should_keep_replicas: - self.settings['replicas'] = replicas + self.settings["replicas"] = replicas logger.debug("RESTORE REPLICAS") - if should_keep_slaves: - self.settings['slaves'] = slaves - logger.debug("RESTORE SLAVES") - if should_keep_replicas or should_keep_slaves: - self.__index.set_settings(self.settings) + if should_keep_replicas: + _resp = self.__client.set_settings(self.index_name, self.settings) + self.__client.wait_for_task(self.index_name, _resp.task_id) if should_keep_rules: - response = self.__index.save_rules(rules, {'forwardToReplicas': True}) - response.wait() - logger.info("Saved rules for index %s with response: {}".format(response), self.index_name) + _resp = self.__client.save_rules(self.index_name, rules, True) + self.__client.wait_for_task(self.index_name, _resp.task_id) + logger.info( + "Saved rules for index %s with response: {}".format(_resp), + self.index_name, + ) if should_keep_synonyms: - response = self.__index.save_synonyms(synonyms, {'forwardToReplicas': True}) - response.wait() - logger.info("Saved synonyms for index %s with response: {}".format(response), self.index_name) + _resp = self.__client.save_synonyms(self.index_name, synonyms, True) + self.__client.wait_for_task(self.index_name, _resp.task_id) + logger.info( + "Saved synonyms for index %s with response: {}".format(_resp), + self.index_name, + ) return counts except AlgoliaException as e: if DEBUG: raise e else: - logger.warning('ERROR DURING REINDEXING %s: %s', self.model, - e) + logger.warning("ERROR DURING REINDEXING %s: %s", self.model, e) diff --git a/algoliasearch_django/registration.py b/algoliasearch_django/registration.py index 2616511..166a0bc 100644 --- a/algoliasearch_django/registration.py +++ b/algoliasearch_django/registration.py @@ -1,21 +1,17 @@ from __future__ import unicode_literals import logging +from django import __version__ as __django__version__ from django.db.models.signals import post_save from django.db.models.signals import pre_delete -from algoliasearch.search_client import SearchClient -from algoliasearch.user_agent import UserAgent +from algoliasearch_django.version import VERSION as __version__ +from algoliasearch.search.client import SearchClientSync from .models import AlgoliaIndex from .settings import SETTINGS -from .version import VERSION -from django import get_version as django_version logger = logging.getLogger(__name__) -UserAgent.add("Algolia for Django", VERSION) -UserAgent.add("Django", django_version()) - class AlgoliaEngineError(Exception): """Something went wrong with Algolia Engine.""" @@ -30,17 +26,18 @@ def __init__(self, settings=SETTINGS): """Initializes the Algolia engine.""" try: - app_id = settings['APPLICATION_ID'] - api_key = settings['API_KEY'] + app_id = settings["APPLICATION_ID"] + api_key = settings["API_KEY"] except KeyError: - raise AlgoliaEngineError( - 'APPLICATION_ID and API_KEY must be defined.') + raise AlgoliaEngineError("APPLICATION_ID and API_KEY must be defined.") - self.__auto_indexing = settings.get('AUTO_INDEXING', True) + self.__auto_indexing = settings.get("AUTO_INDEXING", True) self.__settings = settings self.__registered_models = {} - self.client = SearchClient.create(app_id, api_key) + self.client = SearchClientSync(app_id, api_key) + self.client.add_user_agent("Algolia for Django", __version__) + self.client.add_user_agent("Django", __django__version__) def is_registered(self, model): """Checks whether the given models is registered with Algolia engine""" @@ -56,21 +53,22 @@ def register(self, model, index_cls=AlgoliaIndex, auto_indexing=None): # Check for existing registration. if self.is_registered(model): raise RegistrationError( - '{} is already registered with Algolia engine'.format(model)) + "{} is already registered with Algolia engine".format(model) + ) # Perform the registration. if not issubclass(index_cls, AlgoliaIndex): raise RegistrationError( - '{} should be a subclass of AlgoliaIndex'.format(index_cls)) + "{} should be a subclass of AlgoliaIndex".format(index_cls) + ) index_obj = index_cls(model, self.client, self.__settings) self.__registered_models[model] = index_obj - if (isinstance(auto_indexing, bool) and - auto_indexing) or self.__auto_indexing: + if (isinstance(auto_indexing, bool) and auto_indexing) or self.__auto_indexing: # Connect to the signalling framework. post_save.connect(self.__post_save_receiver, model) pre_delete.connect(self.__pre_delete_receiver, model) - logger.info('REGISTER %s', model) + logger.info("REGISTER %s", model) def unregister(self, model): """ @@ -81,14 +79,15 @@ def unregister(self, model): """ if not self.is_registered(model): raise RegistrationError( - '{} is not registered with Algolia engine'.format(model)) + "{} is not registered with Algolia engine".format(model) + ) # Perform the unregistration. del self.__registered_models[model] # Disconnect from the signalling framework. post_save.disconnect(self.__post_save_receiver, model) pre_delete.disconnect(self.__pre_delete_receiver, model) - logger.info('UNREGISTER %s', model) + logger.info("UNREGISTER %s", model) def get_registered_models(self): """ @@ -101,7 +100,8 @@ def get_adapter(self, model): """Returns the adapter associated with the given model.""" if not self.is_registered(model): raise RegistrationError( - '{} is not registered with Algolia engine'.format(model)) + "{} is not registered with Algolia engine".format(model) + ) return self.__registered_models[model] @@ -145,7 +145,7 @@ def update_records(self, model, qs, batch_size=1000, **kwargs): adapter = self.get_adapter(model) adapter.update_records(qs, batch_size=batch_size, **kwargs) - def raw_search(self, model, query='', params=None): + def raw_search(self, model, query="", params=None): """Performs a search query and returns the parsed JSON.""" if params is None: params = {} @@ -158,10 +158,6 @@ def clear_objects(self, model): adapter = self.get_adapter(model) adapter.clear_objects() - def clear_index(self, model): - # TODO: add deprecatd warning - self.clear_objects(model) - def reindex_all(self, model, batch_size=1000): """ Reindex all the records. @@ -183,12 +179,12 @@ def reset(self, settings=None): def __post_save_receiver(self, instance, **kwargs): """Signal handler for when a registered model has been saved.""" - logger.debug('RECEIVE post_save FOR %s', instance.__class__) + logger.debug("RECEIVE post_save FOR %s", instance.__class__) self.save_record(instance, **kwargs) def __pre_delete_receiver(self, instance, **kwargs): """Signal handler for when a registered model has been deleted.""" - logger.debug('RECEIVE pre_delete FOR %s', instance.__class__) + logger.debug("RECEIVE pre_delete FOR %s", instance.__class__) self.delete_record(instance) diff --git a/algoliasearch_django/settings.py b/algoliasearch_django/settings.py index abbaa61..354f292 100644 --- a/algoliasearch_django/settings.py +++ b/algoliasearch_django/settings.py @@ -1,4 +1,4 @@ from django.conf import settings SETTINGS = settings.ALGOLIA -DEBUG = SETTINGS.get('RAISE_EXCEPTIONS', settings.DEBUG) +DEBUG = SETTINGS.get("RAISE_EXCEPTIONS", settings.DEBUG) diff --git a/algoliasearch_django/version.py b/algoliasearch_django/version.py index aaa4264..189c03b 100644 --- a/algoliasearch_django/version.py +++ b/algoliasearch_django/version.py @@ -1 +1 @@ -VERSION = '3.0.0' +VERSION = "4.0.0" diff --git a/requirements.txt b/requirements.txt index c8cb9b4..0f30fda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,14 @@ -django>=1.7 -algoliasearch>=3.0,<4.0 +Django>=4.0 +# algoliasearch>=4.0,<5.0 +algoliasearch @ git+https://github.com/algolia/algoliasearch-client-python@main # dev dependencies -pypandoc -wheel +factory_boy>=3.0,<4.0 +mock>=5.0,<6.0 +pypandoc>=1.0,<2.0 +pyright>=1.1.389,<2.0 +ruff>=0.7.4,<1.0 +setuptools>=75.0,<76.0 +six>=1.16,<2.0 tox twine -factory_boy -mock +wheel diff --git a/runtests.py b/runtests.py index 4318d94..8d97daa 100755 --- a/runtests.py +++ b/runtests.py @@ -9,12 +9,19 @@ def main(): - os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" django.setup() TestRunner = get_runner(settings) - test_runner = TestRunner() - failures = test_runner.run_tests(['tests']) + test_runner = TestRunner(failfast=True) + # kept here to run a single test + # failures = test_runner.run_tests( + # [ + # "tests.test_index.IndexTestCase.test_reindex_with_rules" + # ] + # ) + failures = test_runner.run_tests(["tests"]) sys.exit(bool(failures)) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index 55a7bda..69affeb 100644 --- a/setup.py +++ b/setup.py @@ -10,45 +10,61 @@ # Allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) -path_readme = os.path.join(os.path.dirname(__file__), 'README.md') +path_readme = os.path.join(os.path.dirname(__file__), "README.md") try: import pypandoc - README = pypandoc.convert_file(path_readme, 'rst') + + README = pypandoc.convert_file(path_readme, "rst") except (IOError, ImportError): with open(path_readme) as readme: README = readme.read() -path_version = os.path.join(os.path.dirname(__file__), - 'algoliasearch_django/version.py') -if sys.version_info[0] == 3: - exec(open(path_version).read()) +path_version = os.path.join( + os.path.dirname(__file__), "algoliasearch_django/version.py" +) +if sys.version_info < (3, 8): + raise RuntimeError("algoliasearch_django 4.x requires Python 3.8+") else: - execfile(path_version) + exec(open(path_version).read()) setup( - name='algoliasearch-django', - version=VERSION, - license='MIT License', - packages=find_packages(exclude=['tests']), - install_requires=['django>=1.7', 'algoliasearch>=3.0,<4.0'], - description='Algolia Search integration for Django', + name="algoliasearch-django", + version="4.0.0", + license="MIT License", + packages=find_packages(exclude=["tests"]), + install_requires=["django>=4.0"], + description="Algolia Search integration for Django", long_description=README, - long_description_content_type='text/markdown', - author='Algolia Team', - author_email='support@algolia.com', - url='https://github.com/algolia/algoliasearch-django', - keywords=['algolia', 'pyalgolia', 'search', 'backend', 'hosted', 'cloud', - 'full-text search', 'faceted search', 'django'], + long_description_content_type="text/markdown", + author="Algolia Team", + author_email="support@algolia.com", + url="https://github.com/algolia/algoliasearch-django", + keywords=[ + "algolia", + "pyalgolia", + "search", + "backend", + "hosted", + "cloud", + "full-text search", + "faceted search", + "django", + ], classifiers=[ - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - 'Topic :: Internet :: WWW/HTTP', - ] + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "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", + "Topic :: Internet :: WWW/HTTP", + ], ) diff --git a/tests/factories.py b/tests/factories.py index ad237d7..3f79076 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,37 +1,36 @@ import factory -from .models import ( - Example, - User, - Website -) +from .models import Example, User, Website class ExampleFactory(factory.django.DjangoModelFactory): uid = factory.Sequence(lambda n: n) - name = factory.Sequence(lambda n: 'Example name-{}'.format(n)) - address = factory.Sequence(lambda n: 'Example address-{}'.format(n)) - lat = factory.Faker('latitude') - lng = factory.Faker('longitude') + name = factory.Sequence(lambda n: "Example name-{}".format(n)) + address = factory.Sequence(lambda n: "Example address-{}".format(n)) + lat = factory.Faker("latitude") + lng = factory.Faker("longitude") class Meta: model = Example class UserFactory(factory.django.DjangoModelFactory): - name = factory.Sequence(lambda n: 'User name-{}'.format(n)) - username = factory.Sequence(lambda n: 'User username-{}'.format(n)) + name = factory.Sequence(lambda n: "User name-{}".format(n)) + username = factory.Sequence(lambda n: "User username-{}".format(n)) + following_count = 0 + followers_count = 0 - _lat = factory.Faker('latitude') - _lng = factory.Faker('longitude') + _lat = factory.Faker("latitude") + _lng = factory.Faker("longitude") class Meta: model = User class WebsiteFactory(factory.django.DjangoModelFactory): - name = factory.Sequence(lambda n: 'Website name-{}'.format(n)) - url = factory.Faker('url') + name = factory.Sequence(lambda n: "Website name-{}".format(n)) + url = factory.Faker("url") + is_online = False class Meta: model = Website diff --git a/tests/models.py b/tests/models.py index 58e5723..679d4d7 100644 --- a/tests/models.py +++ b/tests/models.py @@ -5,10 +5,10 @@ class User(models.Model): name = models.CharField(max_length=30) username = models.CharField(max_length=30, unique=True) bio = models.CharField(max_length=140, blank=True) - followers_count = models.BigIntegerField(default=0) - following_count = models.BigIntegerField(default=0) - _lat = models.FloatField(default=0) - _lng = models.FloatField(default=0) + followers_count = models.BigIntegerField(0) + following_count = models.BigIntegerField(0) + _lat = models.FloatField(0) + _lng = models.FloatField(0) _permissions = models.CharField(max_length=30, blank=True) @property @@ -19,13 +19,13 @@ def location(self): return self._lat, self._lng def permissions(self): - return self._permissions.split(',') + return self._permissions.split(",") class Website(models.Model): name = models.CharField(max_length=100) url = models.URLField() - is_online = models.BooleanField(default=False) + is_online = models.BooleanField(False) class Example(models.Model): @@ -34,7 +34,7 @@ class Example(models.Model): address = models.CharField(max_length=200) lat = models.FloatField() lng = models.FloatField() - is_admin = models.BooleanField(default=False) + is_admin = models.BooleanField(False) category = [] locations = [] index_me = True @@ -70,8 +70,5 @@ def property_string(self): class BlogPost(models.Model): - author = models.ForeignKey( - User, - on_delete=models.CASCADE - ) + author = models.ForeignKey(User, on_delete=models.CASCADE) text = models.TextField(default="") diff --git a/tests/settings.py b/tests/settings.py index e1066c2..c967350 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,46 +1,59 @@ """ Django settings for core project. -Generated by 'django-admin startproject' using Django 1.8.2. +Generated by 'django-admin startproject' using Django 5.1.3. For more information on this file, see -https://docs.djangoproject.com/en/1.8/topics/settings/ +https://docs.djangoproject.com/en/5.1/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.8/ref/settings/ +https://docs.djangoproject.com/en/5.1/ref/settings/ """ import os import time +from pathlib import Path +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SECRET_KEY = 'MillisecondsMatter' -DEBUG = False +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "MillisecondsMatter" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] # Application definition -INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'algoliasearch_django', - 'tests' -) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "algoliasearch_django", + "tests", +] 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', - 'django.middleware.security.SecurityMiddleware', + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.auth.middleware.SessionAuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.security.SecurityMiddleware", ] +ROOT_URLCONF = "tests.urls" + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -57,32 +70,44 @@ }, ] -ROOT_URLCONF = 'tests.urls' - # Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } # Internationalization -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + USE_I18N = True + USE_L10N = True + USE_TZ = True +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + def safe_index_name(name): - return '{}_ci-{}'.format(name, time.time()) + return "{}_ci-{}".format(name, time.time()) + # AlgoliaSearch settings ALGOLIA = { - 'APPLICATION_ID': os.getenv('ALGOLIA_APPLICATION_ID'), - 'API_KEY': os.getenv('ALGOLIA_API_KEY'), - 'INDEX_PREFIX': 'test', - 'INDEX_SUFFIX': safe_index_name('django'), - 'RAISE_EXCEPTIONS': True + "APPLICATION_ID": os.getenv("ALGOLIA_APPLICATION_ID"), + "API_KEY": os.getenv("ALGOLIA_API_KEY"), + "INDEX_PREFIX": "test", + "INDEX_SUFFIX": safe_index_name("django"), + "RAISE_EXCEPTIONS": True, } diff --git a/tests/test_commands.py b/tests/test_commands.py index 5b7e300..7dd8097 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,7 +4,7 @@ from algoliasearch_django import algolia_engine from algoliasearch_django import get_adapter -from algoliasearch_django import clear_index +from algoliasearch_django import clear_objects from .models import Website from .models import User @@ -16,120 +16,145 @@ def tearDownClass(cls): user_index_name = get_adapter(User).index_name website_index_name = get_adapter(Website).index_name - algolia_engine.client.init_index(user_index_name).delete() - algolia_engine.client.init_index(website_index_name).delete() + algolia_engine.client.delete_index(user_index_name) + algolia_engine.client.delete_index(website_index_name) def setUp(self): # Create some records - User.objects.create(name='James Bond', username="jb") - User.objects.create(name='Captain America', username="captain") - User.objects.create(name='John Snow', username="john_snow", - _lat=120.2, _lng=42.1) - User.objects.create(name='Steve Jobs', username="genius", - followers_count=331213) + u = User( + name="James Bond", + username="jb", + followers_count=0, + following_count=0, + _lat=0, + _lng=0, + ) + u.save() + u = User( + name="Captain America", + username="captain", + followers_count=0, + following_count=0, + _lat=0, + _lng=0, + ) + u.save() + u = User( + name="John Snow", + username="john_snow", + _lat=120.2, + _lng=42.1, + followers_count=0, + following_count=0, + ) + u.save() + u = User( + name="Steve Jobs", + username="genius", + followers_count=331213, + following_count=0, + _lat=0, + _lng=0, + ) + u.save() self.out = StringIO() def tearDown(self): - clear_index(Website) - clear_index(User) + clear_objects(Website) + clear_objects(User) def test_reindex(self): - call_command('algolia_reindex', stdout=self.out) + call_command("algolia_reindex", stdout=self.out) result = self.out.getvalue() - regex = r'Website --> 0' + regex = r"Website --> 0" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User --> 4' + regex = r"User --> 4" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) def test_reindex_with_args(self): - call_command('algolia_reindex', stdout=self.out, model=['Website']) + call_command("algolia_reindex", stdout=self.out, model=["Website"]) result = self.out.getvalue() - regex = r'Website --> \d+' + regex = r"Website --> \d+" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User --> \d+' + regex = r"User --> \d+" try: self.assertNotRegex(result, regex) except AttributeError: self.assertNotRegexpMatches(result, regex) def test_clearindex(self): - call_command('algolia_clearindex', stdout=self.out) + call_command("algolia_clearindex", stdout=self.out) result = self.out.getvalue() - regex = r'Website' + regex = r"Website" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User' + regex = r"User" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) def test_clearindex_with_args(self): - call_command( - 'algolia_clearindex', - stdout=self.out, - model=['Website'] - ) + call_command("algolia_clearindex", stdout=self.out, model=["Website"]) result = self.out.getvalue() - regex = r'Website' + regex = r"Website" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User' + regex = r"User" try: self.assertNotRegex(result, regex) except AttributeError: self.assertNotRegexpMatches(result, regex) def test_applysettings(self): - call_command('algolia_applysettings', stdout=self.out) + call_command("algolia_applysettings", stdout=self.out) result = self.out.getvalue() - regex = r'Website' + regex = r"Website" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User' + regex = r"User" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) def test_applysettings_with_args(self): - call_command('algolia_applysettings', stdout=self.out, - model=['Website']) + call_command("algolia_applysettings", stdout=self.out, model=["Website"]) result = self.out.getvalue() - regex = r'Website' + regex = r"Website" try: self.assertRegex(result, regex) except AttributeError: self.assertRegexpMatches(result, regex) - regex = r'User' + regex = r"User" try: self.assertNotRegex(result, regex) except AttributeError: diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 53a9e16..46de051 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,8 +1,4 @@ -from mock import ( - ANY, - call, - patch -) +from mock import ANY, call, patch from django.test import TestCase @@ -26,28 +22,31 @@ def non_decorated_operation(): WebsiteFactory() UserFactory() - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: decorated_operation() # The decorated method should have prevented the indexing operations mocked_save_record.assert_not_called() - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: non_decorated_operation() # The non-decorated method is not preventing the indexing operations # (the signal was correctly re-connected for both of the models) - mocked_save_record.assert_has_calls([ - call( - ANY, - created=True, - raw=False, - sender=ANY, - signal=ANY, - update_fields=None, - using=ANY - ) - ] * 2) + mocked_save_record.assert_has_calls( + [ + call( + ANY, + created=True, + raw=False, + sender=ANY, + signal=ANY, + update_fields=None, + using=ANY, + ) + ] + * 2 + ) def test_disable_auto_indexing_as_decorator_for_model(self): """Test that the `disable_auto_indexing` should work as a decorator for a specific model""" @@ -61,7 +60,7 @@ def non_decorated_operation(): WebsiteFactory() UserFactory() - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: decorated_operation() # The decorated method should have prevented the indexing operation for the `User` model @@ -73,36 +72,39 @@ def non_decorated_operation(): sender=ANY, signal=ANY, update_fields=None, - using=ANY + using=ANY, ) - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: non_decorated_operation() # The non-decorated method is not preventing the indexing operations # (the signal was correctly re-connected for both of the models) - mocked_save_record.assert_has_calls([ - call( - ANY, - created=True, - raw=False, - sender=ANY, - signal=ANY, - update_fields=None, - using=ANY - ) - ] * 2) + mocked_save_record.assert_has_calls( + [ + call( + ANY, + created=True, + raw=False, + sender=ANY, + signal=ANY, + update_fields=None, + using=ANY, + ) + ] + * 2 + ) def test_disable_auto_indexing_as_context_manager(self): """Test that the `disable_auto_indexing` should work as a context manager""" - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: with disable_auto_indexing(): WebsiteFactory() mocked_save_record.assert_not_called() - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: WebsiteFactory() mocked_save_record.assert_called_once() diff --git a/tests/test_engine.py b/tests/test_engine.py index 1d97a79..97be25c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,13 +1,10 @@ import six -import re +from django import __version__ as __django__version__ from django.conf import settings from django.test import TestCase -from algoliasearch.user_agent import UserAgent -from django import get_version as django_version -from algoliasearch_django.version import VERSION -from algoliasearch_django import algolia_engine +from algoliasearch_django import algolia_engine, __version__ from algoliasearch_django import AlgoliaIndex from algoliasearch_django import AlgoliaEngine from algoliasearch_django.registration import AlgoliaEngineError @@ -26,20 +23,20 @@ def tearDown(self): def test_init_exception(self): algolia_settings = dict(settings.ALGOLIA) - del algolia_settings['APPLICATION_ID'] - del algolia_settings['API_KEY'] + del algolia_settings["APPLICATION_ID"] + del algolia_settings["API_KEY"] with self.settings(ALGOLIA=algolia_settings): with self.assertRaises(AlgoliaEngineError): AlgoliaEngine(settings=settings.ALGOLIA) def test_user_agent(self): - user_agent = UserAgent.get() - - parts = re.split('\s*;\s*', user_agent) - - self.assertIn('Django (%s)' % django_version(), parts) - self.assertIn('Algolia for Django (%s)' % VERSION, parts) + self.assertIn( + "Algolia for Django ({}); Django ({})".format( + __version__, __django__version__ + ), + self.engine.client._config._user_agent.get(), + ) def test_auto_discover_indexes(self): """Test that the `index` module was auto-discovered and the models registered""" @@ -50,7 +47,7 @@ def test_auto_discover_indexes(self): User, # Registered using the `register` decorator Website, # Registered using the `register` method ], - algolia_engine.get_registered_models() + algolia_engine.get_registered_models(), ) def test_is_register(self): @@ -60,8 +57,7 @@ def test_is_register(self): def test_get_adapter(self): self.engine.register(Website) - self.assertEquals(AlgoliaIndex, - self.engine.get_adapter(Website).__class__) + self.assertEqual(AlgoliaIndex, self.engine.get_adapter(Website).__class__) def test_get_adapter_exception(self): with self.assertRaises(RegistrationError): @@ -70,9 +66,9 @@ def test_get_adapter_exception(self): def test_get_adapter_from_instance(self): self.engine.register(Website) instance = Website() - self.assertEquals( - AlgoliaIndex, - self.engine.get_adapter_from_instance(instance).__class__) + self.assertEqual( + AlgoliaIndex, self.engine.get_adapter_from_instance(instance).__class__ + ) def test_register(self): self.engine.register(Website) @@ -92,8 +88,9 @@ class WebsiteIndex(AlgoliaIndex): pass self.engine.register(Website, WebsiteIndex) - self.assertEqual(WebsiteIndex.__name__, - self.engine.get_adapter(Website).__class__.__name__) + self.assertEqual( + WebsiteIndex.__name__, self.engine.get_adapter(Website).__class__.__name__ + ) def test_register_with_custom_index_exception(self): class WebsiteIndex(object): diff --git a/tests/test_index.py b/tests/test_index.py index 07b5622..33fbad3 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,9 +1,7 @@ # coding=utf-8 -import time from django.conf import settings from django.test import TestCase -import unittest from algoliasearch_django import AlgoliaIndex from algoliasearch_django import algolia_engine @@ -15,41 +13,45 @@ class IndexTestCase(TestCase): def setUp(self): self.client = algolia_engine.client - self.user = User(name='Algolia', username="algolia", - bio='Milliseconds matter', followers_count=42001, - following_count=42, _lat=123, _lng=-42.24, - _permissions='read,write,admin') + self.user = User( + name="Algolia", + username="algolia", + bio="Milliseconds matter", + followers_count=42001, + following_count=42, + _lat=123, + _lng=-42.24, + _permissions="read,write,admin", + ) + self.website = Website(name="Algolia", url="https://algolia.com") self.contributor = User( - name='Contributor', + name="Contributor", username="contributor", - bio='Contributions matter', + bio="Contributions matter", followers_count=7, following_count=5, _lat=52.0705, _lng=-4.3007, - _permissions='contribute,follow' + _permissions="contribute,follow", ) - self.example = Example(uid=4, - name='SuperK', - address='Finland', - lat=63.3, - lng=-32.0, - is_admin=True) - self.example.category = ['Shop', 'Grocery'] + self.example = Example( + uid=4, name="SuperK", address="Finland", lat=63.3, lng=-32.0, is_admin=True + ) + self.example.category = ["Shop", "Grocery"] self.example.locations = [ - {'lat': 10.3, 'lng': -20.0}, - {'lat': 22.3, 'lng': 10.0}, + {"lat": 10.3, "lng": -20.0}, + {"lat": 22.3, "lng": 10.0}, ] def tearDown(self): - if hasattr(self, 'index'): + if hasattr(self, "index"): self.index.delete() def test_default_index_name(self): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - regex = r'^test_Website_django(_ci-\d+.\d+)?$' + regex = r"^test_Website_django(_ci-\d+.\d+)?$" try: self.assertRegex(self.index.index_name, regex) except AttributeError: @@ -57,10 +59,10 @@ def test_default_index_name(self): def test_custom_index_name(self): class WebsiteIndex(AlgoliaIndex): - index_name = 'customName' + index_name = "customName" self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) - regex = r'^test_customName_django(_ci-\d+.\d+)?$' + regex = r"^test_customName_django(_ci-\d+.\d+)?$" try: self.assertRegex(self.index.index_name, regex) except AttributeError: @@ -73,12 +75,12 @@ def test_index_model_with_foreign_key_reference(self): def test_index_name_settings(self): algolia_settings = dict(settings.ALGOLIA) - del algolia_settings['INDEX_PREFIX'] - del algolia_settings['INDEX_SUFFIX'] + del algolia_settings["INDEX_PREFIX"] + del algolia_settings["INDEX_SUFFIX"] with self.settings(ALGOLIA=algolia_settings): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - regex = r'^Website$' + regex = r"^Website$" try: self.assertRegex(self.index.index_name, regex) except AttributeError: @@ -90,56 +92,44 @@ def test_tmp_index_name(self): algolia_settings = dict(settings.ALGOLIA) # With no suffix nor prefix - del algolia_settings['INDEX_PREFIX'] - del algolia_settings['INDEX_SUFFIX'] + del algolia_settings["INDEX_PREFIX"] + del algolia_settings["INDEX_SUFFIX"] with self.settings(ALGOLIA=algolia_settings): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - self.assertEqual( - self.index.tmp_index_name, - 'Website_tmp' - ) + self.assertEqual(self.index.tmp_index_name, "Website_tmp") # With only a prefix - algolia_settings['INDEX_PREFIX'] = 'prefix' + algolia_settings["INDEX_PREFIX"] = "prefix" with self.settings(ALGOLIA=algolia_settings): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - self.assertEqual( - self.index.tmp_index_name, - 'prefix_Website_tmp' - ) + self.assertEqual(self.index.tmp_index_name, "prefix_Website_tmp") # With only a suffix - del algolia_settings['INDEX_PREFIX'] - algolia_settings['INDEX_SUFFIX'] = 'suffix' + del algolia_settings["INDEX_PREFIX"] + algolia_settings["INDEX_SUFFIX"] = "suffix" with self.settings(ALGOLIA=algolia_settings): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - self.assertEqual( - self.index.tmp_index_name, - 'Website_tmp_suffix' - ) + self.assertEqual(self.index.tmp_index_name, "Website_tmp_suffix") # With a prefix and a suffix - algolia_settings['INDEX_PREFIX'] = 'prefix' - algolia_settings['INDEX_SUFFIX'] = 'suffix' + algolia_settings["INDEX_PREFIX"] = "prefix" + algolia_settings["INDEX_SUFFIX"] = "suffix" with self.settings(ALGOLIA=algolia_settings): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) - self.assertEqual( - self.index.tmp_index_name, - 'prefix_Website_tmp_suffix' - ) + self.assertEqual(self.index.tmp_index_name, "prefix_Website_tmp_suffix") def test_reindex_with_replicas(self): self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) class WebsiteIndex(AlgoliaIndex): settings = { - 'replicas': [ - self.index.index_name + '_name_asc', - self.index.index_name + '_name_desc' + "replicas": [ + self.index.index_name + "_name_asc", # pyright: ignore + self.index.index_name + "_name_desc", # pyright: ignore ] } @@ -147,26 +137,21 @@ class WebsiteIndex(AlgoliaIndex): self.index.reindex_all() def test_reindex_with_should_index_boolean(self): - Website.objects.create( - name='Algolia', - url='https://algolia.com', - is_online=True - ) + Website(name="Algolia", url="https://algolia.com", is_online=True) self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) class WebsiteIndex(AlgoliaIndex): settings = { - 'replicas': [ - self.index.index_name + '_name_asc', - self.index.index_name + '_name_desc' + "replicas": [ + self.index.index_name + "_name_asc", # pyright: ignore + self.index.index_name + "_name_desc", # pyright: ignore ] } - should_index = 'is_online' + should_index = "is_online" self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) self.index.reindex_all() - @unittest.skip(reason="FIXME: it's a known issue that reindex all might not work properly") def test_reindex_no_settings(self): self.maxDiff = None @@ -182,37 +167,56 @@ class WebsiteIndex(AlgoliaIndex): # When reindexing with no settings on the instance self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) self.index.reindex_all() - time.sleep(10) # FIXME: Refactor reindex_all to return taskID # Expect the former settings to be kept across reindex - self.assertEqual(self.index.get_settings(), existing_settings, - "An index whose model has no settings should keep its settings after reindex") + self.assertEqual( + self.index.get_settings(), + existing_settings, + "An index whose model has no settings should keep its settings after reindex", + ) - @unittest.skip(reason="FIXME: it's a known issue that reindex all might not work properly") def test_reindex_with_settings(self): import uuid + id = str(uuid.uuid4()) self.maxDiff = None - index_settings = {'searchableAttributes': ['name', 'email', 'company', 'city', 'county', 'account_names', - 'unordered(address)', 'state', 'zip_code', 'phone', 'fax', - 'unordered(web)'], 'attributesForFaceting': ['city', 'company'], - 'customRanking': ['desc(followers)'], - 'queryType': 'prefixAll', - 'highlightPreTag': '', - 'ranking': [ - 'asc(name)', - 'typo', - 'geo', - 'words', - 'filters', - 'proximity', - 'attribute', - 'exact', - 'custom' - ], - 'replicas': ['WebsiteIndexReplica_' + id + '_name_asc', - 'WebsiteIndexReplica_' + id + '_name_desc'], - 'highlightPostTag': '', 'hitsPerPage': 15} + index_settings = { + "searchableAttributes": [ + "name", + "email", + "company", + "city", + "county", + "account_names", + "unordered(address)", + "state", + "zip_code", + "phone", + "fax", + "unordered(web)", + ], + "attributesForFaceting": ["city", "company"], + "customRanking": ["desc(followers)"], + "queryType": "prefixAll", + "highlightPreTag": "", + "ranking": [ + "asc(name)", + "typo", + "geo", + "words", + "filters", + "proximity", + "attribute", + "exact", + "custom", + ], + "replicas": [ + "WebsiteIndexReplica_" + id + "_name_asc", + "WebsiteIndexReplica_" + id + "_name_desc", + ], + "highlightPostTag": "", + "hitsPerPage": 15, + } # Given an existing index defined with settings class WebsiteIndex(AlgoliaIndex): @@ -229,78 +233,101 @@ class WebsiteIndex(AlgoliaIndex): # When reindexing with no settings on the instance self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) self.index.reindex_all() - time.sleep(10) # FIXME: Refactor reindex_all to return taskID # Expect the settings to be reset to model definition over reindex former_settings = existing_settings former_settings["hitsPerPage"] = 15 - self.assertDictEqual(self.index.get_settings(), former_settings) - @unittest.skip(reason="FIXME: it's a known issue that reindex all might not work properly") + new_settings = self.index.get_settings() + + self.assertIsNotNone(new_settings) + + if new_settings is not None: + self.assertDictEqual(new_settings, former_settings) + def test_reindex_with_rules(self): # Given an existing index defined with settings class WebsiteIndex(AlgoliaIndex): - settings = {'hitsPerPage': 42} + settings = {"hitsPerPage": 42} self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) - underlying_index = self.index._AlgoliaIndex__index # Given some existing query rules on the index rule = { - 'objectID': 'my-rule', - 'condition': { - 'pattern': 'some text', - 'anchoring': 'is' - }, - 'consequence': { - 'params': { - 'query': 'other text' - } - } + "objectID": "my-rule", + "condition": {"pattern": "some text", "anchoring": "is"}, + "consequence": {"params": {"hitsPerPage": 42}}, } - underlying_index.save_rule(rule).wait() + self.assertIsNotNone(self.index.index_name) + + if self.index.index_name is None: + return + + _resp = self.client.save_rule(self.index.index_name, rule["objectID"], rule) + self.client.wait_for_task(self.index.index_name, _resp.task_id) # When reindexing with no settings on the instance self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) self.index.reindex_all() - time.sleep(10) # FIXME: Refactor reindex_all to return taskID - - # Expect the rules to be kept across reindex - def remove_metadata(rule): - copy = dict(rule) - del copy["_metadata"] - return copy - rules = [r for r in underlying_index.browse_rules()] - rules = list(map(remove_metadata, rules)) + rules = [] + self.client.browse_rules( + self.index.index_name, + lambda _resp: rules.extend([_hit.to_dict() for _hit in _resp.hits]), + ) self.assertEqual(len(rules), 1, "There should only be one rule") - self.assertIn(rule, rules, "The existing rule should be kept over reindex") + self.assertEqual( + rules[0]["consequence"], + rule["consequence"], + "The existing rule should be kept over reindex", + ) + self.assertEqual( + rules[0]["objectID"], + rule["objectID"], + "The existing rule should be kept over reindex", + ) - @unittest.skip(reason="FIXME: it's a known issue that reindex all might not work properly") def test_reindex_with_synonyms(self): # Given an existing index defined with settings class WebsiteIndex(AlgoliaIndex): - settings = {'hitsPerPage': 42} + settings = {"hitsPerPage": 42} self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) - underlying_index = self.index._AlgoliaIndex__index + + self.assertIsNotNone(self.index.index_name) + + if self.index.index_name is None: + return # Given some existing synonyms on the index - synonym = {'objectID': 'street', 'type': 'altCorrection1', 'word': 'Street', 'corrections': ['St']} - underlying_index.save_synonyms([synonym]).wait() + synonym = { + "objectID": "street", + "type": "altCorrection1", + "word": "Street", + "corrections": ["St"], + } + save_synonyms_response = self.client.save_synonyms( + self.index.index_name, synonym_hit=[synonym] + ) + self.client.wait_for_task(self.index.index_name, save_synonyms_response.task_id) # When reindexing with no settings on the instance self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) self.index.reindex_all() - time.sleep(10) # FIXME: Refactor reindex_all to return taskID # Expect the synonyms to be kept across reindex - synonyms = [s for s in underlying_index.browse_synonyms()] + synonyms = [] + self.client.browse_synonyms( + self.index.index_name, + lambda _resp: synonyms.extend([_hit.to_dict() for _hit in _resp.hits]), + ) self.assertEqual(len(synonyms), 1, "There should only be one synonym") - self.assertIn(synonym, synonyms, "The existing synonym should be kept over reindex") + self.assertIn( + synonym, synonyms, "The existing synonym should be kept over reindex" + ) - def apply_some_settings(self, index): + def apply_some_settings(self, index) -> dict: """ Applies a sample setting to the index. @@ -308,365 +335,402 @@ def apply_some_settings(self, index): :return: the new settings """ # When reindexing with settings on the instance - old_hpp = index.settings['hitsPerPage'] if 'hitsPerPage' in index.settings else None - index.settings['hitsPerPage'] = 42 + old_hpp = ( + index.settings["hitsPerPage"] if "hitsPerPage" in index.settings else None + ) + index.settings["hitsPerPage"] = 42 index.reindex_all() - index.settings['hitsPerPage'] = old_hpp - time.sleep(10) # FIXME: Refactor reindex_all to return taskID + index.settings["hitsPerPage"] = old_hpp index_settings = index.get_settings() # Expect the instance's settings to be applied at reindex - self.assertEqual(index_settings['hitsPerPage'], 42, - "An index whose model has settings should apply those at reindex") + self.assertEqual( + index_settings["hitsPerPage"], + 42, + "An index whose model has settings should apply those at reindex", + ) return index_settings def test_custom_objectID(self): class UserIndex(AlgoliaIndex): - custom_objectID = 'username' + custom_objectID = "username" self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertEqual(obj['objectID'], 'algolia') + self.assertEqual(obj["objectID"], "algolia") def test_custom_objectID_property(self): class UserIndex(AlgoliaIndex): - custom_objectID = 'reverse_username' + custom_objectID = "reverse_username" self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertEqual(obj['objectID'], 'ailogla') + self.assertEqual(obj["objectID"], "ailogla") def test_invalid_custom_objectID(self): class UserIndex(AlgoliaIndex): - custom_objectID = 'uid' + custom_objectID = "uid" with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_geo_fields(self): class UserIndex(AlgoliaIndex): - geo_field = 'location' + geo_field = "location" self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertEqual(obj['_geoloc'], {'lat': 123, 'lng': -42.24}) + self.assertEqual(obj["_geoloc"], {"lat": 123, "lng": -42.24}) def test_several_geo_fields(self): class ExampleIndex(AlgoliaIndex): - geo_field = 'geolocations' + geo_field = "geolocations" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.example) - self.assertEqual(obj['_geoloc'], [ - {'lat': 10.3, 'lng': -20.0}, - {'lat': 22.3, 'lng': 10.0}, - ]) + self.assertEqual( + obj["_geoloc"], + [ + {"lat": 10.3, "lng": -20.0}, + {"lat": 22.3, "lng": 10.0}, + ], + ) def test_geo_fields_already_formatted(self): class ExampleIndex(AlgoliaIndex): - geo_field = 'geolocations' + geo_field = "geolocations" - self.example.locations = {'lat': 10.3, 'lng': -20.0} + self.example.locations = {"lat": 10.3, "lng": -20.0} self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.example) - self.assertEqual(obj['_geoloc'], {'lat': 10.3, 'lng': -20.0}) + self.assertEqual(obj["_geoloc"], {"lat": 10.3, "lng": -20.0}) def test_none_geo_fields(self): class ExampleIndex(AlgoliaIndex): - geo_field = 'location' + geo_field = "location" Example.location = lambda x: None self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.example) - self.assertIsNone(obj.get('_geoloc')) + self.assertIsNone(obj.get("_geoloc")) def test_invalid_geo_fields(self): class UserIndex(AlgoliaIndex): - geo_field = 'position' + geo_field = "position" with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_tags(self): class UserIndex(AlgoliaIndex): - tags = 'permissions' + tags = "permissions" self.index = UserIndex(User, self.client, settings.ALGOLIA) # Test the users' tag individually obj = self.index.get_raw_record(self.user) - self.assertListEqual(obj['_tags'], ['read', 'write', 'admin']) + self.assertListEqual(obj["_tags"], ["read", "write", "admin"]) obj = self.index.get_raw_record(self.contributor) - self.assertListEqual(obj['_tags'], ['contribute', 'follow']) + self.assertListEqual(obj["_tags"], ["contribute", "follow"]) def test_invalid_tags(self): class UserIndex(AlgoliaIndex): - tags = 'tags' + tags = "tags" with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_one_field(self): class UserIndex(AlgoliaIndex): - fields = 'name' + fields = "name" self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertIn('name', obj) - self.assertNotIn('username', obj) - self.assertNotIn('bio', obj) - self.assertNotIn('followers_count', obj) - self.assertNotIn('following_count', obj) - self.assertNotIn('_lat', obj) - self.assertNotIn('_lng', obj) - self.assertNotIn('_permissions', obj) - self.assertNotIn('location', obj) - self.assertNotIn('_geoloc', obj) - self.assertNotIn('permissions', obj) - self.assertNotIn('_tags', obj) + self.assertIn("name", obj) + self.assertNotIn("username", obj) + self.assertNotIn("bio", obj) + self.assertNotIn("followers_count", obj) + self.assertNotIn("following_count", obj) + self.assertNotIn("_lat", obj) + self.assertNotIn("_lng", obj) + self.assertNotIn("_permissions", obj) + self.assertNotIn("location", obj) + self.assertNotIn("_geoloc", obj) + self.assertNotIn("permissions", obj) + self.assertNotIn("_tags", obj) self.assertEqual(len(obj), 2) def test_multiple_fields(self): class UserIndex(AlgoliaIndex): - fields = ('name', 'username', 'bio') + fields = ("name", "username", "bio") self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertIn('name', obj) - self.assertIn('username', obj) - self.assertIn('bio', obj) - self.assertNotIn('followers_count', obj) - self.assertNotIn('following_count', obj) - self.assertNotIn('_lat', obj) - self.assertNotIn('_lng', obj) - self.assertNotIn('_permissions', obj) - self.assertNotIn('location', obj) - self.assertNotIn('_geoloc', obj) - self.assertNotIn('permissions', obj) - self.assertNotIn('_tags', obj) + self.assertIn("name", obj) + self.assertIn("username", obj) + self.assertIn("bio", obj) + self.assertNotIn("followers_count", obj) + self.assertNotIn("following_count", obj) + self.assertNotIn("_lat", obj) + self.assertNotIn("_lng", obj) + self.assertNotIn("_permissions", obj) + self.assertNotIn("location", obj) + self.assertNotIn("_geoloc", obj) + self.assertNotIn("permissions", obj) + self.assertNotIn("_tags", obj) self.assertEqual(len(obj), 4) def test_fields_with_custom_name(self): # tuple syntax class UserIndex(AlgoliaIndex): - fields = ('name', ('username', 'login'), 'bio') + fields = ("name", ("username", "login"), "bio") self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertIn('name', obj) - self.assertNotIn('username', obj) - self.assertIn('login', obj) - self.assertEqual(obj['login'], 'algolia') - self.assertIn('bio', obj) - self.assertNotIn('followers_count', obj) - self.assertNotIn('following_count', obj) - self.assertNotIn('_lat', obj) - self.assertNotIn('_lng', obj) - self.assertNotIn('_permissions', obj) - self.assertNotIn('location', obj) - self.assertNotIn('_geoloc', obj) - self.assertNotIn('permissions', obj) - self.assertNotIn('_tags', obj) + self.assertIn("name", obj) + self.assertNotIn("username", obj) + self.assertIn("login", obj) + self.assertEqual(obj["login"], "algolia") + self.assertIn("bio", obj) + self.assertNotIn("followers_count", obj) + self.assertNotIn("following_count", obj) + self.assertNotIn("_lat", obj) + self.assertNotIn("_lng", obj) + self.assertNotIn("_permissions", obj) + self.assertNotIn("location", obj) + self.assertNotIn("_geoloc", obj) + self.assertNotIn("permissions", obj) + self.assertNotIn("_tags", obj) self.assertEqual(len(obj), 4) # list syntax class UserIndex(AlgoliaIndex): - fields = ('name', ['username', 'login'], 'bio') + fields = ("name", ["username", "login"], "bio") self.index = UserIndex(User, self.client, settings.ALGOLIA) obj = self.index.get_raw_record(self.user) - self.assertIn('name', obj) - self.assertNotIn('username', obj) - self.assertIn('login', obj) - self.assertEqual(obj['login'], 'algolia') - self.assertIn('bio', obj) - self.assertNotIn('followers_count', obj) - self.assertNotIn('following_count', obj) - self.assertNotIn('_lat', obj) - self.assertNotIn('_lng', obj) - self.assertNotIn('_permissions', obj) - self.assertNotIn('location', obj) - self.assertNotIn('_geoloc', obj) - self.assertNotIn('permissions', obj) - self.assertNotIn('_tags', obj) + self.assertIn("name", obj) + self.assertNotIn("username", obj) + self.assertIn("login", obj) + self.assertEqual(obj["login"], "algolia") + self.assertIn("bio", obj) + self.assertNotIn("followers_count", obj) + self.assertNotIn("following_count", obj) + self.assertNotIn("_lat", obj) + self.assertNotIn("_lng", obj) + self.assertNotIn("_permissions", obj) + self.assertNotIn("location", obj) + self.assertNotIn("_geoloc", obj) + self.assertNotIn("permissions", obj) + self.assertNotIn("_tags", obj) self.assertEqual(len(obj), 4) def test_invalid_fields(self): class UserIndex(AlgoliaIndex): - fields = ('name', 'color') + fields = ("name", "color") with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_invalid_fields_syntax(self): class UserIndex(AlgoliaIndex): - fields = {'name': 'user_name'} + fields = {"name": "user_name"} with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_invalid_named_fields_syntax(self): class UserIndex(AlgoliaIndex): - fields = ('name', {'username': 'login'}) + fields = ("name", {"username": "login"}) with self.assertRaises(AlgoliaIndexError): UserIndex(User, self.client, settings.ALGOLIA) def test_get_raw_record_with_update_fields(self): class UserIndex(AlgoliaIndex): - fields = ('name', 'username', ['bio', 'description']) + fields = ("name", "username", ["bio", "description"]) self.index = UserIndex(User, self.client, settings.ALGOLIA) - obj = self.index.get_raw_record(self.user, - update_fields=('name', 'bio')) - self.assertIn('name', obj) - self.assertNotIn('username', obj) - self.assertNotIn('bio', obj) - self.assertIn('description', obj) - self.assertEqual(obj['description'], 'Milliseconds matter') - self.assertNotIn('followers_count', obj) - self.assertNotIn('following_count', obj) - self.assertNotIn('_lat', obj) - self.assertNotIn('_lng', obj) - self.assertNotIn('_permissions', obj) - self.assertNotIn('location', obj) - self.assertNotIn('_geoloc', obj) - self.assertNotIn('permissions', obj) - self.assertNotIn('_tags', obj) + obj = self.index.get_raw_record(self.user, update_fields=("name", "bio")) + self.assertIn("name", obj) + self.assertNotIn("username", obj) + self.assertNotIn("bio", obj) + self.assertIn("description", obj) + self.assertEqual(obj["description"], "Milliseconds matter") + self.assertNotIn("followers_count", obj) + self.assertNotIn("following_count", obj) + self.assertNotIn("_lat", obj) + self.assertNotIn("_lng", obj) + self.assertNotIn("_permissions", obj) + self.assertNotIn("location", obj) + self.assertNotIn("_geoloc", obj) + self.assertNotIn("permissions", obj) + self.assertNotIn("_tags", obj) self.assertEqual(len(obj), 3) def test_should_index_method(self): class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'has_name' + fields = "name" + should_index = "has_name" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertTrue(self.index._should_index(self.example), - "We should index an instance when should_index(instance) returns True") + self.assertTrue( + self.index._should_index(self.example), + "We should index an instance when should_index(instance) returns True", + ) instance_should_not = Example(name=None) - self.assertFalse(self.index._should_index(instance_should_not), - "We should not index an instance when should_index(instance) returns False") + self.assertFalse( + self.index._should_index(instance_should_not), + "We should not index an instance when should_index(instance) returns False", + ) def test_should_index_unbound(self): class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'static_should_index' + fields = "name" + should_index = "static_should_index" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertTrue(self.index._should_index(self.example), - "We should index an instance when should_index() returns True") + self.assertTrue( + self.index._should_index(self.example), + "We should index an instance when should_index() returns True", + ) class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'static_should_not_index' + fields = "name" + should_index = "static_should_not_index" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) instance_should_not = Example() - self.assertFalse(self.index._should_index(instance_should_not), - "We should not index an instance when should_index() returns False") + self.assertFalse( + self.index._should_index(instance_should_not), + "We should not index an instance when should_index() returns False", + ) def test_should_index_attr(self): class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'index_me' + fields = "name" + should_index = "index_me" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertTrue(self.index._should_index(self.example), - "We should index an instance when its should_index attr is True") + self.assertTrue( + self.index._should_index(self.example), + "We should index an instance when its should_index attr is True", + ) instance_should_not = Example() instance_should_not.index_me = False - self.assertFalse(self.index._should_index(instance_should_not), - "We should not index an instance when its should_index attr is False") + self.assertFalse( + self.index._should_index(instance_should_not), + "We should not index an instance when its should_index attr is False", + ) class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'category' + fields = "name" + should_index = "category" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - with self.assertRaises(AlgoliaIndexError, msg="We should raise when the should_index attr is not boolean"): + with self.assertRaises( + AlgoliaIndexError, + msg="We should raise when the should_index attr is not boolean", + ): self.index._should_index(self.example) def test_should_index_field(self): class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'is_admin' + fields = "name" + should_index = "is_admin" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertTrue(self.index._should_index(self.example), - "We should index an instance when its should_index field is True") + self.assertTrue( + self.index._should_index(self.example), + "We should index an instance when its should_index field is True", + ) instance_should_not = Example() instance_should_not.is_admin = False - self.assertFalse(self.index._should_index(instance_should_not), - "We should not index an instance when its should_index field is False") + self.assertFalse( + self.index._should_index(instance_should_not), + "We should not index an instance when its should_index field is False", + ) class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'name' + fields = "name" + should_index = "name" self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - with self.assertRaises(AlgoliaIndexError, msg="We should raise when the should_index field is not boolean"): + with self.assertRaises( + AlgoliaIndexError, + msg="We should raise when the should_index field is not boolean", + ): self.index._should_index(self.example) def test_should_index_property(self): - class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'property_should_index' + class ExampleIndex1(AlgoliaIndex): + fields = "name" + should_index = "property_should_index" + + self.index = ExampleIndex1(Example, self.client, settings.ALGOLIA) + self.assertTrue( + self.index._should_index(self.example), + "We should index an instance when its should_index property is True", + ) - self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertTrue(self.index._should_index(self.example), - "We should index an instance when its should_index property is True") + class ExampleIndex2(AlgoliaIndex): + fields = "name" + should_index = "property_should_not_index" - class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'property_should_not_index' + self.index = ExampleIndex2(Example, self.client, settings.ALGOLIA) + self.assertFalse( + self.index._should_index(self.example), + "We should not index an instance when its should_index property is False", + ) - self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - self.assertFalse(self.index._should_index(self.example), - "We should not index an instance when its should_index property is False") + class ExampleIndex3(AlgoliaIndex): + fields = "name" + should_index = "property_string" - class ExampleIndex(AlgoliaIndex): - fields = 'name' - should_index = 'property_string' - - self.index = ExampleIndex(Example, self.client, settings.ALGOLIA) - with self.assertRaises(AlgoliaIndexError, msg="We should raise when the should_index property is not boolean"): + self.index = ExampleIndex3(Example, self.client, settings.ALGOLIA) + with self.assertRaises( + AlgoliaIndexError, + msg="We should raise when the should_index property is not boolean", + ): self.index._should_index(self.example) def test_save_record_should_index_boolean(self): - website = Website.objects.create( - name='Algolia', - url='https://algolia.com', - is_online=True - ) self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA) class WebsiteIndex(AlgoliaIndex): + custom_objectID = "name" settings = { - 'replicas': [ - self.index.index_name + '_name_asc', - self.index.index_name + '_name_desc' + "replicas": [ + self.index.index_name + "_name_asc", # pyright: ignore + self.index.index_name + "_name_desc", # pyright: ignore ] } - should_index = 'is_online' + should_index = "is_online" + self.website.is_online = True self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA) - self.index.save_record(website) + self.index.save_record(self.website) def test_cyrillic(self): class CyrillicIndex(AlgoliaIndex): - fields = ['bio', 'name'] + fields = ["bio", "name"] settings = { - 'searchableAttributes': ['name', 'bio'], + "searchableAttributes": ["name", "bio"], } index_name = "test_cyrillic" self.user.bio = "крупнейших" self.user.save() self.index = CyrillicIndex(User, self.client, settings.ALGOLIA) - self.index.save_record(self.user).wait() + self.index.save_record(self.user) result = self.index.raw_search("крупнейших") - self.assertEqual(result['nbHits'], 1, "Search should return one result") - self.assertEqual(result['hits'][0]['name'], 'Algolia', "The result should be self.user") + self.assertIsNotNone(result) + + if result is not None: + self.assertEqual(result["nbHits"], 1, "Search should return one result") + self.assertEqual( + result["hits"][0]["name"], "Algolia", "The result should be self.user" + ) diff --git a/tests/test_signal.py b/tests/test_signal.py index e576bd3..4ad9d02 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -1,12 +1,12 @@ import time from mock import patch, call, ANY -from django.test import TestCase, override_settings +from django.test import TestCase from algoliasearch_django import algolia_engine from algoliasearch_django import get_adapter from algoliasearch_django import raw_search -from algoliasearch_django import clear_index +from algoliasearch_django import clear_objects from algoliasearch_django import update_records from .factories import WebsiteFactory @@ -14,16 +14,15 @@ class SignalTestCase(TestCase): - @classmethod def tearDownClass(cls): get_adapter(Website).delete() def tearDown(self): - clear_index(Website) + clear_objects(Website) def test_save_signal(self): - with patch.object(algolia_engine, 'save_record') as mocked_save_record: + with patch.object(algolia_engine, "save_record") as mocked_save_record: websites = WebsiteFactory.create_batch(3) mocked_save_record.assert_has_calls( @@ -35,7 +34,7 @@ def test_save_signal(self): sender=ANY, signal=ANY, update_fields=None, - using=ANY + using=ANY, ) for website in websites ] @@ -44,31 +43,26 @@ def test_save_signal(self): def test_delete_signal(self): websites = WebsiteFactory.create_batch(3) - with patch.object(algolia_engine, 'delete_record') as mocked_delete_record: + with patch.object(algolia_engine, "delete_record") as mocked_delete_record: websites[0].delete() websites[1].delete() - mocked_delete_record.assert_has_calls( - [ - call(websites[0]), - call(websites[1]) - ] - ) + mocked_delete_record.assert_has_calls([call(websites[0]), call(websites[1])]) def test_update_records(self): - Website.objects.create(name='Algolia', url='https://www.algolia.com') - Website.objects.create(name='Google', url='https://www.google.com') - Website.objects.create(name='Facebook', url='https://www.facebook.com') - Website.objects.create(name='Facebook', url='https://www.facebook.fr') - Website.objects.create(name='Facebook', url='https://fb.com') + Website(name="Algolia", url="https://www.algolia.com", is_online=False) + Website(name="Google", url="https://www.google.com", is_online=False) + Website(name="Facebook", url="https://www.facebook.com", is_online=False) + Website(name="Facebook", url="https://www.facebook.fr", is_online=False) + Website(name="Facebook", url="https://fb.com", is_online=False) - qs = Website.objects.filter(name='Facebook') - update_records(Website, qs, url='https://facebook.com') + qs = Website.objects.filter(name="Facebook") + update_records(Website, qs, url="https://facebook.com") time.sleep(10) - qs.update(url='https://facebook.com') + qs.update(url="https://facebook.com") time.sleep(10) - result = raw_search(Website, 'Facebook') - self.assertEqual(result['nbHits'], qs.count()) - for res, url in zip(result['hits'], qs.values_list('url', flat=True)): - self.assertEqual(res['url'], url) + result = raw_search(Website, "Facebook") + self.assertEqual(result["nbHits"], qs.count()) + for res, url in zip(result["hits"], qs.values_list("url", flat=True)): + self.assertEqual(res["url"], url) diff --git a/tox.ini b/tox.ini index 939d0c3..592e645 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,10 @@ [tox] envlist = - {py34}-django17 - {py34,py35,py36}-django18 - {py34,py35,py36}-django19 - {py34,py35,py36}-django110 - {py34,py35,py36}-django111 - {py34,py35,py36}-django20 - {py34,py35,py36}-django21 - {py34,py35,py36}-django22LTS - {py36,py37,py38,py39}-django30 - {py36,py37,py38,py39}-django31 - {py36,py37,py38,py39,py310}-django32 {py38,py39,py310}-django40 {py38,py39,py310,py311}-django41 - {py38,py39,py310,py311}-django42 + {py38,py39,py310,py311,py312}-django42 + {py310,py311,py312}-django50 + {py310,py311,py312,py313}-django51 coverage skip_missing_interpreters = True @@ -22,31 +13,26 @@ deps = six mock factory_boy - py{34,311}: Faker>=1.0,<2.0 - django17: Django>=1.7,<1.8 - django18: Django>=1.8,<1.9 - django19: Django>=1.9,<1.10 - django110: Django>=1.10,<1.11 - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 - django22LTS: Django>=2.2,<3.0 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 - django32: Django>=3.2,<3.3 + py{38,313}: Faker>=5.0,<6.0 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 passenv = ALGOLIA* -commands = python runtests.py +commands = + pip3 install -r requirements.txt + python runtests.py [versions] -twine = >=1.13,<2.0 -wheel = >=0.34,<1.0 +twine = >=5.1,<6.0 +wheel = >=0.45,<1.0 +ruff = >=0.7.4,<1.0 +pyright = >=1.1.389,<2.0 [testenv:coverage] -basepython = python3.8 +basepython = python3.13 deps = coverage passenv = ALGOLIA* @@ -55,7 +41,7 @@ commands = coverage report [testenv:coveralls] -basepython = python3.8 +basepython = python3.13 deps = coverage coveralls @@ -67,7 +53,7 @@ commands = coveralls [testenv:release] -basepython = python3.8 +basepython = python3.13 deps = twine {[versions]twine} wheel {[versions]wheel} @@ -78,3 +64,13 @@ commands = python setup.py sdist bdist_wheel twine check dist/* twine upload -u {env:PYPI_USER} -p {env:PYPI_PASSWORD} --repository-url https://upload.pypi.org/legacy/ dist/* + +[testenv:lint] +deps = + ruff {[versions]ruff} + pyright {[versions]pyright} +commands = + pip3 install -r requirements.txt + ruff check --fix --unsafe-fixes + ruff format . + pyright algoliasearch_django