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, + )