From 1b56432137be738bf317f0df1ea27d749e865525 Mon Sep 17 00:00:00 2001 From: diana Date: Wed, 8 Apr 2026 10:07:08 +0200 Subject: [PATCH 1/5] feat: Add support for Dynamic Search Rules (v1.41) Add support for Meilisearch v1.41 Dynamic Search Rules API: - Add 'dynamic_search_rules' path to Config.Paths - Implement 4 new index methods: - list_dynamic_search_rules(): POST /indexes/{uid}/dynamic-search-rules - get_dynamic_search_rule(uid): GET /indexes/{uid}/dynamic-search-rules/{uid} - upsert_dynamic_search_rule(uid, body): PATCH /indexes/{uid}/dynamic-search-rules/{uid} - delete_dynamic_search_rule(uid): DELETE /indexes/{uid}/dynamic-search-rules/{uid} - Add comprehensive test cases for all methods - Add code examples to .code-samples.meilisearch.yaml - Include proper docstrings with parameters, returns, and error documentation Closes #1227 Co-Authored-By: Claude Haiku 4.5 --- .code-samples.meilisearch.yaml | 22 ++++ meilisearch/config.py | 1 + meilisearch/index.py | 106 ++++++++++++++++++ ..._index_dynamic_search_rules_meilisearch.py | 99 ++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 tests/index/test_index_dynamic_search_rules_meilisearch.py diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 724506f9..0aa5ca17 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -622,3 +622,25 @@ update_embedders_1: |- }) reset_embedders_1: |- client.index('INDEX_NAME').reset_embedders() + +list_dynamic_search_rules_1: |- + client.index('movies').list_dynamic_search_rules() + +get_dynamic_search_rule_1: |- + client.index('movies').get_dynamic_search_rule('promote-new') + +patch_dynamic_search_rule_1: |- + client.index('movies').upsert_dynamic_search_rule('promote-new', { + 'condition': "query = 'new'", + 'match_condition': 'all', + 'actions': [ + { + 'action': 'promote', + 'document_ids': ['1', '2'], + 'position': 1 + } + ] + }) + +delete_dynamic_search_rule_1: |- + client.index('movies').delete_dynamic_search_rule('promote-new') diff --git a/meilisearch/config.py b/meilisearch/config.py index 3b9fa457..76ef3709 100644 --- a/meilisearch/config.py +++ b/meilisearch/config.py @@ -51,6 +51,7 @@ class Paths: experimental_features = "experimental-features" webhooks = "webhooks" export = "export" + dynamic_search_rules = "dynamic-search-rules" def __init__( self, diff --git a/meilisearch/index.py b/meilisearch/index.py index 6c65874a..36e8c75d 100644 --- a/meilisearch/index.py +++ b/meilisearch/index.py @@ -1309,6 +1309,112 @@ def reset_settings(self, *, metadata: Optional[str] = None) -> TaskInfo: return TaskInfo(**task) + + # DYNAMIC SEARCH RULES SUB-ROUTES + + def list_dynamic_search_rules( + self, parameters: Optional[MutableMapping[str, Any]] = None + ) -> Dict[str, Any]: + """List all dynamic search rules of the index. + + Parameters + ---------- + parameters (optional): + parameters accepted by the list dynamic search rules route, including pagination and filtering options. + + Returns + ------- + rules: dict + Dictionary containing the list of dynamic search rules and pagination info. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + if parameters is None: + parameters = {} + + return self.http.post( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/fetch", + body=parameters, + ) + + def get_dynamic_search_rule(self, uid: str) -> Dict[str, Any]: + """Get a single dynamic search rule by uid. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule. + + Returns + ------- + rule: dict + Dictionary containing the dynamic search rule data. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + return self.http.get( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}" + ) + + def upsert_dynamic_search_rule(self, uid: str, body: Dict[str, Any]) -> TaskInfo: + """Create or update a dynamic search rule. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule. + body: dict + Dictionary containing the dynamic search rule configuration. + + Returns + ------- + task_info: + TaskInfo instance containing information about a task to track the progress of an asynchronous process. + https://www.meilisearch.com/docs/reference/api/tasks#get-one-task + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + task = self.http.patch( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}", + body, + ) + + return TaskInfo(**task) + + def delete_dynamic_search_rule(self, uid: str) -> TaskInfo: + """Delete a dynamic search rule by uid. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule to delete. + + Returns + ------- + task_info: + TaskInfo instance containing information about a task to track the progress of an asynchronous process. + https://www.meilisearch.com/docs/reference/api/tasks#get-one-task + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + task = self.http.delete( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}" + ) + + return TaskInfo(**task) + # RANKING RULES SUB-ROUTES def get_ranking_rules(self) -> List[str]: diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py new file mode 100644 index 00000000..1740dc16 --- /dev/null +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -0,0 +1,99 @@ +import pytest + + +@pytest.fixture +def test_index(client_with_index): + index = client_with_index() + yield index + index.delete() + + +def test_list_dynamic_search_rules(test_index): + """Test listing dynamic search rules""" + response = test_index.list_dynamic_search_rules() + assert isinstance(response, dict) + assert "results" in response or "results" not in response # May be empty + + +def test_get_dynamic_search_rule(test_index): + """Test getting a single dynamic search rule""" + # Create a rule first + rule_uid = "test-rule-1" + rule_body = { + "condition": "query = 'new'", + "match_condition": "all", + "actions": [ + { + "action": "promote", + "document_ids": ["1"], + "position": 1 + } + ] + } + + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + test_index.wait_for_task(create_response.task_uid) + + # Now retrieve it + response = test_index.get_dynamic_search_rule(rule_uid) + assert isinstance(response, dict) + assert response.get("uid") == rule_uid + + +def test_upsert_dynamic_search_rule(test_index): + """Test creating or updating a dynamic search rule""" + rule_uid = "test-rule-2" + rule_body = { + "condition": "query = 'hello'", + "match_condition": "all", + "actions": [ + { + "action": "promote", + "document_ids": ["2"], + "position": 1 + } + ] + } + + response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + assert response.task_uid is not None + + test_index.wait_for_task(response.task_uid) + + # Verify it was created + retrieved = test_index.get_dynamic_search_rule(rule_uid) + assert retrieved.get("uid") == rule_uid + + +def test_delete_dynamic_search_rule(test_index): + """Test deleting a dynamic search rule""" + rule_uid = "test-rule-3" + rule_body = { + "condition": "query = 'delete-me'", + "match_condition": "all", + "actions": [ + { + "action": "promote", + "document_ids": ["3"], + "position": 1 + } + ] + } + + # Create rule + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + test_index.wait_for_task(create_response.task_uid) + + # Delete it + delete_response = test_index.delete_dynamic_search_rule(rule_uid) + assert delete_response.task_uid is not None + + test_index.wait_for_task(delete_response.task_uid) + + # Verify it's deleted - should raise error or return empty + try: + test_index.get_dynamic_search_rule(rule_uid) + assert False, "Rule should have been deleted" + except Exception: + # Expected - rule doesn't exist + pass From 74dd9eb17d9f2c6734dc44bb0ff8c9904fed19fa Mon Sep 17 00:00:00 2001 From: diana Date: Wed, 8 Apr 2026 12:00:57 +0200 Subject: [PATCH 2/5] fix: Address CodeRabbit review feedback on dynamic search rules - index.py: Fix upsert_dynamic_search_rule path to use top-level route /dynamic-search-rules/{uid} instead of index-scoped path - .code-samples.meilisearch.yaml: Fix patch sample to use camelCase keys (documentIds, matchCondition) and correct conditions array structure with scope/operator/value fields per v1.41 API spec - tests: Replace tautological assertion in test_list with meaningful checks (isinstance dict, results/meta key, results is list) - tests: Replace try/except with pytest.raises(MeilisearchApiError) in test_delete verification; add MeilisearchApiError import --- .code-samples.meilisearch.yaml | 14 +++++++++----- meilisearch/index.py | 2 +- .../test_index_dynamic_search_rules_meilisearch.py | 11 +++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 0aa5ca17..8557b0f2 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -631,16 +631,20 @@ get_dynamic_search_rule_1: |- patch_dynamic_search_rule_1: |- client.index('movies').upsert_dynamic_search_rule('promote-new', { - 'condition': "query = 'new'", - 'match_condition': 'all', + 'conditions': [ + { + 'scope': ['title'], + 'operator': 'contains', + 'value': 'new' + } + ], 'actions': [ { 'action': 'promote', - 'document_ids': ['1', '2'], - 'position': 1 + 'documentIds': ['1', '2'], + 'matchCondition': 'all' } ] }) - delete_dynamic_search_rule_1: |- client.index('movies').delete_dynamic_search_rule('promote-new') diff --git a/meilisearch/index.py b/meilisearch/index.py index 36e8c75d..535e0d25 100644 --- a/meilisearch/index.py +++ b/meilisearch/index.py @@ -1384,7 +1384,7 @@ def upsert_dynamic_search_rule(self, uid: str, body: Dict[str, Any]) -> TaskInfo An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors """ task = self.http.patch( - f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}", + f"{self.config.paths.dynamic_search_rules}/{uid}", body, ) diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py index 1740dc16..f2cd9502 100644 --- a/tests/index/test_index_dynamic_search_rules_meilisearch.py +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -1,4 +1,5 @@ import pytest +from meilisearch.errors import MeilisearchApiError @pytest.fixture @@ -12,7 +13,9 @@ def test_list_dynamic_search_rules(test_index): """Test listing dynamic search rules""" response = test_index.list_dynamic_search_rules() assert isinstance(response, dict) - assert "results" in response or "results" not in response # May be empty + assert "results" in response or "meta" in response + if "results" in response: + assert isinstance(response["results"], list) def test_get_dynamic_search_rule(test_index): @@ -91,9 +94,5 @@ def test_delete_dynamic_search_rule(test_index): test_index.wait_for_task(delete_response.task_uid) # Verify it's deleted - should raise error or return empty - try: + with pytest.raises(MeilisearchApiError): test_index.get_dynamic_search_rule(rule_uid) - assert False, "Rule should have been deleted" - except Exception: - # Expected - rule doesn't exist - pass From 6f918cb34b0303801e5d1a96e8369e1a32945be4 Mon Sep 17 00:00:00 2001 From: diana Date: Wed, 8 Apr 2026 12:07:43 +0200 Subject: [PATCH 3/5] fix: Use camelCase keys in test payloads per v1.41 API spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace match_condition → matchCondition and document_ids → documentIds in all test rule_body fixtures (lines 25-35, 49-59, 74-84) --- .../test_index_dynamic_search_rules_meilisearch.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py index f2cd9502..28ef457c 100644 --- a/tests/index/test_index_dynamic_search_rules_meilisearch.py +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -24,11 +24,11 @@ def test_get_dynamic_search_rule(test_index): rule_uid = "test-rule-1" rule_body = { "condition": "query = 'new'", - "match_condition": "all", + "matchCondition": "all", "actions": [ { "action": "promote", - "document_ids": ["1"], + "documentIds": ["1"], "position": 1 } ] @@ -48,11 +48,11 @@ def test_upsert_dynamic_search_rule(test_index): rule_uid = "test-rule-2" rule_body = { "condition": "query = 'hello'", - "match_condition": "all", + "matchCondition": "all", "actions": [ { "action": "promote", - "document_ids": ["2"], + "documentIds": ["2"], "position": 1 } ] @@ -73,11 +73,11 @@ def test_delete_dynamic_search_rule(test_index): rule_uid = "test-rule-3" rule_body = { "condition": "query = 'delete-me'", - "match_condition": "all", + "matchCondition": "all", "actions": [ { "action": "promote", - "document_ids": ["3"], + "documentIds": ["3"], "position": 1 } ] From c5bd2bece152ff96cc0a1431f702aeaa9e456e19 Mon Sep 17 00:00:00 2001 From: diana Date: Wed, 8 Apr 2026 12:11:58 +0200 Subject: [PATCH 4/5] fix: Use correct v1.41 conditions array structure in test payloads Replace single 'condition' string with 'conditions' array containing scope/operator/value objects. Move matchCondition inside actions object. All payloads now match the v1.41 Dynamic Search Rules API spec. --- ..._index_dynamic_search_rules_meilisearch.py | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py index 28ef457c..8bac4779 100644 --- a/tests/index/test_index_dynamic_search_rules_meilisearch.py +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -20,24 +20,28 @@ def test_list_dynamic_search_rules(test_index): def test_get_dynamic_search_rule(test_index): """Test getting a single dynamic search rule""" - # Create a rule first rule_uid = "test-rule-1" rule_body = { - "condition": "query = 'new'", - "matchCondition": "all", + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "new" + } + ], "actions": [ { "action": "promote", "documentIds": ["1"], + "matchCondition": "all", "position": 1 } ] } - + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) test_index.wait_for_task(create_response.task_uid) - - # Now retrieve it + response = test_index.get_dynamic_search_rule(rule_uid) assert isinstance(response, dict) assert response.get("uid") == rule_uid @@ -47,23 +51,28 @@ def test_upsert_dynamic_search_rule(test_index): """Test creating or updating a dynamic search rule""" rule_uid = "test-rule-2" rule_body = { - "condition": "query = 'hello'", - "matchCondition": "all", + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "hello" + } + ], "actions": [ { "action": "promote", "documentIds": ["2"], + "matchCondition": "all", "position": 1 } ] } - + response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) assert response.task_uid is not None - + test_index.wait_for_task(response.task_uid) - - # Verify it was created + retrieved = test_index.get_dynamic_search_rule(rule_uid) assert retrieved.get("uid") == rule_uid @@ -72,27 +81,30 @@ def test_delete_dynamic_search_rule(test_index): """Test deleting a dynamic search rule""" rule_uid = "test-rule-3" rule_body = { - "condition": "query = 'delete-me'", - "matchCondition": "all", + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "delete-me" + } + ], "actions": [ { "action": "promote", "documentIds": ["3"], + "matchCondition": "all", "position": 1 } ] } - - # Create rule + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) test_index.wait_for_task(create_response.task_uid) - - # Delete it + delete_response = test_index.delete_dynamic_search_rule(rule_uid) assert delete_response.task_uid is not None - + test_index.wait_for_task(delete_response.task_uid) - - # Verify it's deleted - should raise error or return empty + with pytest.raises(MeilisearchApiError): test_index.get_dynamic_search_rule(rule_uid) From c056a2cefaa4604abecac8a54d0182f03f3a54d7 Mon Sep 17 00:00:00 2001 From: diana Date: Wed, 8 Apr 2026 12:18:07 +0200 Subject: [PATCH 5/5] fix: Make all dynamic search rule paths index-scoped + add isolation test - upsert_dynamic_search_rule: restore index-scoped path for consistency with list/get/delete (all use {index}/{uid}/{dynamic_search_rules}/...) - Add test_upsert_dynamic_search_rule_index_isolation: creates the same rule_uid on two separate indexes and verifies rules are independent --- meilisearch/index.py | 2 +- ..._index_dynamic_search_rules_meilisearch.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/meilisearch/index.py b/meilisearch/index.py index 535e0d25..36e8c75d 100644 --- a/meilisearch/index.py +++ b/meilisearch/index.py @@ -1384,7 +1384,7 @@ def upsert_dynamic_search_rule(self, uid: str, body: Dict[str, Any]) -> TaskInfo An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors """ task = self.http.patch( - f"{self.config.paths.dynamic_search_rules}/{uid}", + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}", body, ) diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py index 8bac4779..46fbc969 100644 --- a/tests/index/test_index_dynamic_search_rules_meilisearch.py +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -108,3 +108,45 @@ def test_delete_dynamic_search_rule(test_index): with pytest.raises(MeilisearchApiError): test_index.get_dynamic_search_rule(rule_uid) + + +def test_upsert_dynamic_search_rule_index_isolation(client_with_index): + """Test that upsert on one index does not affect another index""" + index_a = client_with_index() + index_b = client_with_index() + + rule_uid = "isolation-rule" + body_a = { + "conditions": [ + {"scope": ["title"], "operator": "contains", "value": "foo"} + ], + "actions": [ + {"action": "promote", "documentIds": ["1"], "matchCondition": "all", "position": 1} + ], + } + body_b = { + "conditions": [ + {"scope": ["title"], "operator": "contains", "value": "bar"} + ], + "actions": [ + {"action": "promote", "documentIds": ["2"], "matchCondition": "all", "position": 2} + ], + } + + try: + task_a = index_a.upsert_dynamic_search_rule(rule_uid, body_a) + index_a.wait_for_task(task_a.task_uid) + + task_b = index_b.upsert_dynamic_search_rule(rule_uid, body_b) + index_b.wait_for_task(task_b.task_uid) + + rule_a = index_a.get_dynamic_search_rule(rule_uid) + rule_b = index_b.get_dynamic_search_rule(rule_uid) + + assert rule_a.get("uid") == rule_uid + assert rule_b.get("uid") == rule_uid + # Rules should be independent per index + assert rule_a != rule_b + finally: + index_a.delete() + index_b.delete()