From 4ea8b6ed5a48376e2147b4db186a37ac4e6c6f16 Mon Sep 17 00:00:00 2001 From: Frank Barrett Date: Mon, 20 Jul 2026 22:09:09 -0700 Subject: [PATCH] Add dynamic search rules support Expose the v1.50 experimental endpoints with typed responses and task metadata so callers can list, retrieve, upsert, and delete rules. Cover the released API contract with integration tests and executable documentation samples. Co-Authored-By: Codex --- .code-samples.meilisearch.yaml | 25 ++++ meilisearch/client.py | 112 ++++++++++++++++++ meilisearch/config.py | 1 + meilisearch/models/search_rule.py | 23 ++++ ...client_dynamic_search_rules_meilisearch.py | 110 +++++++++++++++++ tests/conftest.py | 27 +++++ 6 files changed, 298 insertions(+) create mode 100644 meilisearch/models/search_rule.py create mode 100644 tests/client/test_client_dynamic_search_rules_meilisearch.py diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 04c0be8c..f2e0cf28 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -612,6 +612,31 @@ get_experimental_features_1: |- client.get_experimental_features() update_experimental_features_1: |- client.update_experimental_features({"metrics": True}) +list_dynamic_search_rules_1: |- + client.get_dynamic_search_rules() +get_dynamic_search_rule_1: |- + client.get_dynamic_search_rule('black-friday') +patch_dynamic_search_rule_1: |- + client.update_dynamic_search_rule('black-friday', { + 'description': 'Black Friday 2025 rules', + 'precedence': 10, + 'active': True, + 'conditions': { + 'query': {'isEmpty': True}, + 'time': { + 'start': '2025-11-28T00:00:00Z', + 'end': '2025-11-28T23:59:59Z' + } + }, + 'actions': [ + { + 'selector': {'indexUid': 'products', 'id': '123'}, + 'action': {'type': 'pin', 'position': 1} + } + ] + }) +delete_dynamic_search_rule_1: |- + client.delete_dynamic_search_rule('black-friday') get_embedders_1: |- client.index('INDEX_NAME').get_embedders() update_embedders_1: |- diff --git a/meilisearch/client.py b/meilisearch/client.py index 6391973a..32281a82 100644 --- a/meilisearch/client.py +++ b/meilisearch/client.py @@ -19,6 +19,7 @@ from meilisearch.index import Index from meilisearch.models.index import SizeFormat from meilisearch.models.key import Key, KeysResults +from meilisearch.models.search_rule import SearchRule, SearchRulesResults from meilisearch.models.task import Batch, BatchResults, Task, TaskInfo, TaskResults from meilisearch.models.webhook import Webhook, WebhooksResults from meilisearch.task import TaskHandler @@ -648,6 +649,117 @@ def delete_webhook(self, webhook_uuid: str) -> int: response = self.http.delete(f"{self.config.paths.webhooks}/{webhook_uuid}") return response.status_code + # DYNAMIC SEARCH RULES ROUTES + + def get_dynamic_search_rules( + self, parameters: Mapping[str, Any] | None = None + ) -> SearchRulesResults: + """Get dynamic search rules with optional pagination and filtering. + + Parameters + ---------- + parameters (optional): + Pagination and filter parameters accepted by the list dynamic search rules route. + + Returns + ------- + search_rules: + SearchRulesResults containing the matching rules and pagination information. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process the request. + """ + if parameters is None: + parameters = {} + + search_rules = self.http.post(self.config.paths.dynamic_search_rules, parameters) + return SearchRulesResults(**search_rules) + + def get_dynamic_search_rule(self, uid: str) -> SearchRule: + """Get a dynamic search rule by UID. + + Parameters + ---------- + uid: + UID of the dynamic search rule. + + Returns + ------- + search_rule: + The requested dynamic search rule. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process the request. + """ + search_rule = self.http.get(f"{self.config.paths.dynamic_search_rules}/{uid}") + return SearchRule(**search_rule) + + def update_dynamic_search_rule( + self, + uid: str, + options: Mapping[str, Any], + *, + metadata: str | None = None, + ) -> TaskInfo: + """Update a dynamic search rule, or create it when it doesn't exist. + + Parameters + ---------- + uid: + UID of the dynamic search rule. + options: + Fields to update on the dynamic search rule. + metadata (optional): + Custom metadata string to attach to the task. + + Returns + ------- + task_info: + Information about the enqueued update task. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process the request. + """ + url = f"{self.config.paths.dynamic_search_rules}/{uid}" + if metadata is not None: + url += f"?{parse.urlencode({'customMetadata': metadata})}" + + task = self.http.patch(url, options) + return TaskInfo(**task) + + def delete_dynamic_search_rule(self, uid: str, *, metadata: str | None = None) -> TaskInfo: + """Delete a dynamic search rule. + + Parameters + ---------- + uid: + UID of the dynamic search rule. + metadata (optional): + Custom metadata string to attach to the task. + + Returns + ------- + task_info: + Information about the enqueued deletion task. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process the request. + """ + url = f"{self.config.paths.dynamic_search_rules}/{uid}" + if metadata is not None: + url += f"?{parse.urlencode({'customMetadata': metadata})}" + + task = self.http.delete(url) + return TaskInfo(**task) + def get_version(self) -> dict[str, str]: """Get version Meilisearch diff --git a/meilisearch/config.py b/meilisearch/config.py index fd53a50e..993d7d38 100644 --- a/meilisearch/config.py +++ b/meilisearch/config.py @@ -48,6 +48,7 @@ class Paths: edit = "edit" network = "network" experimental_features = "experimental-features" + dynamic_search_rules = "dynamic-search-rules" webhooks = "webhooks" export = "export" render_template = "render-template" diff --git a/meilisearch/models/search_rule.py b/meilisearch/models/search_rule.py new file mode 100644 index 00000000..8d446490 --- /dev/null +++ b/meilisearch/models/search_rule.py @@ -0,0 +1,23 @@ +from typing import Any + +from camel_converter.pydantic_base import CamelBase + + +class SearchRule(CamelBase): + """A dynamic search rule configured on a Meilisearch instance.""" + + uid: str + description: str | None = None + precedence: int | None = None + active: bool + conditions: dict[str, Any] + actions: list[dict[str, Any]] + + +class SearchRulesResults(CamelBase): + """A paginated list of dynamic search rules.""" + + results: list[SearchRule] + offset: int + limit: int + total: int diff --git a/tests/client/test_client_dynamic_search_rules_meilisearch.py b/tests/client/test_client_dynamic_search_rules_meilisearch.py new file mode 100644 index 00000000..bc537ba1 --- /dev/null +++ b/tests/client/test_client_dynamic_search_rules_meilisearch.py @@ -0,0 +1,110 @@ +"""Tests for dynamic search rule endpoints.""" + +from copy import deepcopy + +import pytest + +from meilisearch.errors import MeilisearchApiError +from meilisearch.models.task import TaskInfo + +pytestmark = pytest.mark.usefixtures("enable_dynamic_search_rules") + +RULE_OPTIONS = { + "description": "Black Friday products", + "precedence": 10, + "active": True, + "conditions": {"query": {"words": "black friday"}}, + "actions": [ + { + "selector": {"indexUid": "products", "id": "123"}, + "action": {"type": "pin", "position": 1}, + } + ], +} + + +def _upsert_and_wait(client, uid, options): + task = client.update_dynamic_search_rule(uid, options) + client.wait_for_task(task.task_uid) + return task + + +def test_get_dynamic_search_rules_with_pagination_and_filters(client): + _upsert_and_wait(client, "black-friday", RULE_OPTIONS) + + inactive_options = deepcopy(RULE_OPTIONS) + inactive_options.update({"description": "Archived promotion", "active": False}) + _upsert_and_wait(client, "archived-promotion", inactive_options) + + all_rules = client.get_dynamic_search_rules() + assert all_rules.total == 2 + + first_page = client.get_dynamic_search_rules({"offset": 0, "limit": 1}) + assert first_page.offset == 0 + assert first_page.limit == 1 + assert first_page.total == 2 + assert len(first_page.results) == 1 + + filtered = client.get_dynamic_search_rules( + {"filter": {"query": "Black Friday", "active": True}} + ) + assert filtered.total == 1 + assert filtered.results[0].uid == "black-friday" + + +def test_get_dynamic_search_rule(client): + _upsert_and_wait(client, "black-friday", RULE_OPTIONS) + + rule = client.get_dynamic_search_rule("black-friday") + + assert rule.uid == "black-friday" + assert rule.description == RULE_OPTIONS["description"] + assert rule.precedence == RULE_OPTIONS["precedence"] + assert rule.actions == RULE_OPTIONS["actions"] + + +def test_update_dynamic_search_rule_creates_rule_and_returns_task(client): + task = client.update_dynamic_search_rule( + "black-friday", RULE_OPTIONS, metadata="create promotion" + ) + + assert isinstance(task, TaskInfo) + assert task.status == "enqueued" + assert task.type == "dsrUpdate" + + completed_task = client.wait_for_task(task.task_uid) + assert completed_task.custom_metadata == "create promotion" + assert client.get_dynamic_search_rule("black-friday").description == "Black Friday products" + + +def test_update_dynamic_search_rule_updates_rule_and_returns_task(client): + _upsert_and_wait(client, "black-friday", RULE_OPTIONS) + + task = client.update_dynamic_search_rule( + "black-friday", + {"description": "Black Friday and Cyber Monday", "precedence": 5}, + ) + + assert isinstance(task, TaskInfo) + assert task.status == "enqueued" + + client.wait_for_task(task.task_uid) + updated_rule = client.get_dynamic_search_rule("black-friday") + assert updated_rule.description == "Black Friday and Cyber Monday" + assert updated_rule.precedence == 5 + assert updated_rule.actions == RULE_OPTIONS["actions"] + + +def test_delete_dynamic_search_rule_returns_task(client): + _upsert_and_wait(client, "black-friday", RULE_OPTIONS) + + task = client.delete_dynamic_search_rule("black-friday", metadata="remove promotion") + + assert isinstance(task, TaskInfo) + assert task.status == "enqueued" + assert task.type == "dsrUpdate" + + completed_task = client.wait_for_task(task.task_uid) + assert completed_task.custom_metadata == "remove promotion" + with pytest.raises(MeilisearchApiError): + client.get_dynamic_search_rule("black-friday") diff --git a/tests/conftest.py b/tests/conftest.py index 8379c319..73f9dabe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -386,3 +386,30 @@ def enable_foreign_keys(): json={"foreignKeys": False}, timeout=10, ) + + +@fixture +def enable_dynamic_search_rules(client): + headers = {"Authorization": f"Bearer {common.MASTER_KEY}"} + requests.patch( + f"{common.BASE_URL}/experimental-features", + headers=headers, + json={"dynamicSearchRules": True}, + timeout=10, + ) + yield + + response = requests.delete( + f"{common.BASE_URL}/dynamic-search-rules", + headers=headers, + timeout=10, + ) + if response.status_code == 202: + client.wait_for_task(response.json()["taskUid"], timeout_in_ms=TEST_TASK_TIMEOUT_MS) + + requests.patch( + f"{common.BASE_URL}/experimental-features", + headers=headers, + json={"dynamicSearchRules": False}, + timeout=10, + )