Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .code-samples.meilisearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |-
Expand Down
112 changes: 112 additions & 0 deletions meilisearch/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions meilisearch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions meilisearch/models/search_rule.py
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions tests/client/test_client_dynamic_search_rules_meilisearch.py
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading