From fba5eb33021fedb70a05f3c0f3d3956b1941e4db Mon Sep 17 00:00:00 2001 From: Harisaran G Date: Tue, 26 Aug 2025 13:27:13 +0530 Subject: [PATCH 01/33] add: analytics API --- examples/analytics_operations.py | 12 +- src/typesense/analytics.py | 46 +--- src/typesense/analytics_events.py | 73 ++++++ src/typesense/analytics_rule.py | 98 ++----- src/typesense/analytics_rule_v1.py | 106 ++++++++ src/typesense/analytics_rules.py | 170 +++---------- src/typesense/analytics_rules_v1.py | 165 ++++++++++++ src/typesense/analytics_v1.py | 44 ++++ src/typesense/client.py | 5 +- src/typesense/types/analytics.py | 84 ++++++ ...analytics_rule.py => analytics_rule_v1.py} | 4 +- src/typesense/types/collection.py | 1 + tests/analytics_events_test.py | 140 ++++++++++ tests/analytics_rule_test.py | 121 +++------ tests/analytics_rule_v1_test.py | 129 ++++++++++ tests/analytics_rules_test.py | 240 ++++++------------ tests/analytics_rules_v1_test.py | 234 +++++++++++++++++ tests/analytics_test.py | 9 +- tests/analytics_v1_test.py | 27 ++ tests/client_test.py | 6 +- tests/collection_test.py | 1 + tests/collections_test.py | 5 + ...rule_fixtures.py => analytics_fixtures.py} | 34 ++- tests/fixtures/analytics_rule_v1_fixtures.py | 70 +++++ tests/import_test.py | 6 +- tests/synonym_test.py | 18 ++ tests/synonyms_test.py | 18 ++ tests/utils/version.py | 20 ++ 28 files changed, 1350 insertions(+), 536 deletions(-) create mode 100644 src/typesense/analytics_events.py create mode 100644 src/typesense/analytics_rule_v1.py create mode 100644 src/typesense/analytics_rules_v1.py create mode 100644 src/typesense/analytics_v1.py create mode 100644 src/typesense/types/analytics.py rename src/typesense/types/{analytics_rule.py => analytics_rule_v1.py} (98%) create mode 100644 tests/analytics_events_test.py create mode 100644 tests/analytics_rule_v1_test.py create mode 100644 tests/analytics_rules_v1_test.py create mode 100644 tests/analytics_v1_test.py rename tests/fixtures/{analytics_rule_fixtures.py => analytics_fixtures.py} (75%) create mode 100644 tests/fixtures/analytics_rule_v1_fixtures.py create mode 100644 tests/utils/version.py diff --git a/examples/analytics_operations.py b/examples/analytics_operations.py index c625c99..6593baf 100644 --- a/examples/analytics_operations.py +++ b/examples/analytics_operations.py @@ -12,12 +12,12 @@ # Drop pre-existing rule if any try: - client.analytics.rules['top_queries'].delete() + client.analyticsV1.rules['top_queries'].delete() except Exception as e: pass # Create a new rule -create_response = client.analytics.rules.create({ +create_response = client.analyticsV1.rules.create({ "name": "top_queries", "type": "popular_queries", "params": { @@ -33,10 +33,10 @@ print(create_response) # Try to fetch it back -print(client.analytics.rules['top_queries'].retrieve()) +print(client.analyticsV1.rules['top_queries'].retrieve()) # Update the rule -update_response = client.analytics.rules.upsert('top_queries', { +update_response = client.analyticsV1.rules.upsert('top_queries', { "name": "top_queries", "type": "popular_queries", "params": { @@ -52,7 +52,7 @@ print(update_response) # List all rules -print(client.analytics.rules.retrieve()) +print(client.analyticsV1.rules.retrieve()) # Delete the rule -print(client.analytics.rules['top_queries'].delete()) +print(client.analyticsV1.rules['top_queries'].delete()) diff --git a/src/typesense/analytics.py b/src/typesense/analytics.py index 941cca5..3463748 100644 --- a/src/typesense/analytics.py +++ b/src/typesense/analytics.py @@ -1,42 +1,24 @@ -""" -This module provides functionality for managing analytics in Typesense. +"""Client for Typesense Analytics module.""" -Classes: - - Analytics: Handles operations related to analytics, including access to analytics rules. +import sys -Methods: - - __init__: Initializes the Analytics object. +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing -The Analytics class serves as an entry point for analytics-related operations in Typesense, -currently providing access to AnalyticsRules. - -For more information on analytics, refer to the Analytics & Query Suggestion -[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) - -This module uses type hinting and is compatible with Python 3.11+ as well as earlier -versions through the use of the typing_extensions library. -""" - -from typesense.analytics_rules import AnalyticsRules from typesense.api_call import ApiCall +from typesense.analytics_events import AnalyticsEvents +from typesense.analytics_rules import AnalyticsRules -class Analytics(object): - """ - Class for managing analytics in Typesense. +class Analytics: + """Client for v30 Analytics endpoints.""" - This class provides access to analytics-related functionalities, - currently including operations on analytics rules. + def __init__(self, api_call: ApiCall) -> None: + self.api_call = api_call + self.rules = AnalyticsRules(api_call) + self.events = AnalyticsEvents(api_call) - Attributes: - rules (AnalyticsRules): An instance of AnalyticsRules for managing analytics rules. - """ - def __init__(self, api_call: ApiCall) -> None: - """ - Initialize the Analytics object. - Args: - api_call (ApiCall): The API call object for making requests. - """ - self.rules = AnalyticsRules(api_call) diff --git a/src/typesense/analytics_events.py b/src/typesense/analytics_events.py new file mode 100644 index 0000000..c462e6c --- /dev/null +++ b/src/typesense/analytics_events.py @@ -0,0 +1,73 @@ +"""Client for Analytics events and status operations.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.analytics import ( + AnalyticsEvent as AnalyticsEventSchema, + AnalyticsEventCreateResponse, + AnalyticsEventsResponse, + AnalyticsStatus, +) + + +class AnalyticsEvents: + events_path: typing.Final[str] = "/analytics/events" + flush_path: typing.Final[str] = "/analytics/flush" + status_path: typing.Final[str] = "/analytics/status" + + def __init__(self, api_call: ApiCall) -> None: + self.api_call = api_call + + def create(self, event: AnalyticsEventSchema) -> AnalyticsEventCreateResponse: + response: AnalyticsEventCreateResponse = self.api_call.post( + AnalyticsEvents.events_path, + body=event, + as_json=True, + entity_type=AnalyticsEventCreateResponse, + ) + return response + + def retrieve( + self, + *, + user_id: str, + name: str, + n: int, + ) -> AnalyticsEventsResponse: + params: typing.Dict[str, typing.Union[str, int]] = { + "user_id": user_id, + "name": name, + "n": n, + } + response: AnalyticsEventsResponse = self.api_call.get( + AnalyticsEvents.events_path, + params=params, + as_json=True, + entity_type=AnalyticsEventsResponse, + ) + return response + + def flush(self) -> AnalyticsEventCreateResponse: + response: AnalyticsEventCreateResponse = self.api_call.post( + AnalyticsEvents.flush_path, + body={}, + as_json=True, + entity_type=AnalyticsEventCreateResponse, + ) + return response + + def status(self) -> AnalyticsStatus: + response: AnalyticsStatus = self.api_call.get( + AnalyticsEvents.status_path, + as_json=True, + entity_type=AnalyticsStatus, + ) + return response + + diff --git a/src/typesense/analytics_rule.py b/src/typesense/analytics_rule.py index 29e9a64..d9c21b2 100644 --- a/src/typesense/analytics_rule.py +++ b/src/typesense/analytics_rule.py @@ -1,24 +1,4 @@ -""" -This module provides functionality for managing individual analytics rules in Typesense. - -Classes: - - AnalyticsRule: Handles operations related to a specific analytics rule. - -Methods: - - __init__: Initializes the AnalyticsRule object. - - _endpoint_path: Constructs the API endpoint path for this specific analytics rule. - - retrieve: Retrieves the details of this specific analytics rule. - - delete: Deletes this specific analytics rule. - -The AnalyticsRule class interacts with the Typesense API to manage operations on a -specific analytics rule. It provides methods to retrieve and delete individual rules. - -For more information on analytics, refer to the Analytics & Query Suggestion -[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) - -This module uses type hinting and is compatible with Python 3.11+ as well as earlier -versions through the use of the typing_extensions library. -""" +"""Per-rule client for Analytics rules operations.""" import sys @@ -28,77 +8,33 @@ import typing_extensions as typing from typesense.api_call import ApiCall -from typesense.types.analytics_rule import ( - RuleDeleteSchema, - RuleSchemaForCounters, - RuleSchemaForQueries, -) +from typesense.types.analytics import AnalyticsRule class AnalyticsRule: - """ - Class for managing individual analytics rules in Typesense. - - This class provides methods to interact with a specific analytics rule, - including retrieving and deleting it. - - Attributes: - api_call (ApiCall): The API call object for making requests. - rule_id (str): The ID of the analytics rule. - """ - - def __init__(self, api_call: ApiCall, rule_id: str): - """ - Initialize the AnalyticsRule object. - - Args: - api_call (ApiCall): The API call object for making requests. - rule_id (str): The ID of the analytics rule. - """ + def __init__(self, api_call: ApiCall, rule_name: str) -> None: self.api_call = api_call - self.rule_id = rule_id + self.rule_name = rule_name + + @property + def _endpoint_path(self) -> str: + from typesense.analytics_rules import AnalyticsRules - def retrieve( - self, - ) -> typing.Union[RuleSchemaForQueries, RuleSchemaForCounters]: - """ - Retrieve this specific analytics rule. + return "/".join([AnalyticsRules.resource_path, self.rule_name]) - Returns: - Union[RuleSchemaForQueries, RuleSchemaForCounters]: - The schema containing the rule details. - """ - response: typing.Union[RuleSchemaForQueries, RuleSchemaForCounters] = ( - self.api_call.get( - self._endpoint_path, - entity_type=typing.Union[RuleSchemaForQueries, RuleSchemaForCounters], - as_json=True, - ) + def retrieve(self) -> AnalyticsRule: + response: AnalyticsRule = self.api_call.get( + self._endpoint_path, + as_json=True, + entity_type=AnalyticsRule, ) return response - def delete(self) -> RuleDeleteSchema: - """ - Delete this specific analytics rule. - - Returns: - RuleDeleteSchema: The schema containing the deletion response. - """ - response: RuleDeleteSchema = self.api_call.delete( + def delete(self) -> AnalyticsRule: + response: AnalyticsRule = self.api_call.delete( self._endpoint_path, - entity_type=RuleDeleteSchema, + entity_type=AnalyticsRule, ) - return response - @property - def _endpoint_path(self) -> str: - """ - Construct the API endpoint path for this specific analytics rule. - - Returns: - str: The constructed endpoint path. - """ - from typesense.analytics_rules import AnalyticsRules - return "/".join([AnalyticsRules.resource_path, self.rule_id]) diff --git a/src/typesense/analytics_rule_v1.py b/src/typesense/analytics_rule_v1.py new file mode 100644 index 0000000..dc6890d --- /dev/null +++ b/src/typesense/analytics_rule_v1.py @@ -0,0 +1,106 @@ +""" +This module provides functionality for managing individual analytics rules in Typesense (V1). + +Classes: + - AnalyticsRuleV1: Handles operations related to a specific analytics rule. + +Methods: + - __init__: Initializes the AnalyticsRuleV1 object. + - _endpoint_path: Constructs the API endpoint path for this specific analytics rule. + - retrieve: Retrieves the details of this specific analytics rule. + - delete: Deletes this specific analytics rule. + +The AnalyticsRuleV1 class interacts with the Typesense API to manage operations on a +specific analytics rule. It provides methods to retrieve and delete individual rules. + +For more information on analytics, refer to the Analytics & Query Suggestion +[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) + +This module uses type hinting and is compatible with Python 3.11+ as well as earlier +versions through the use of the typing_extensions library. +""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.analytics_rule_v1 import ( + RuleDeleteSchema, + RuleSchemaForCounters, + RuleSchemaForQueries, +) + + +class AnalyticsRuleV1: + """ + Class for managing individual analytics rules in Typesense (V1). + + This class provides methods to interact with a specific analytics rule, + including retrieving and deleting it. + + Attributes: + api_call (ApiCall): The API call object for making requests. + rule_id (str): The ID of the analytics rule. + """ + + def __init__(self, api_call: ApiCall, rule_id: str): + """ + Initialize the AnalyticsRuleV1 object. + + Args: + api_call (ApiCall): The API call object for making requests. + rule_id (str): The ID of the analytics rule. + """ + self.api_call = api_call + self.rule_id = rule_id + + def retrieve( + self, + ) -> typing.Union[RuleSchemaForQueries, RuleSchemaForCounters]: + """ + Retrieve this specific analytics rule. + + Returns: + Union[RuleSchemaForQueries, RuleSchemaForCounters]: + The schema containing the rule details. + """ + response: typing.Union[RuleSchemaForQueries, RuleSchemaForCounters] = ( + self.api_call.get( + self._endpoint_path, + entity_type=typing.Union[RuleSchemaForQueries, RuleSchemaForCounters], + as_json=True, + ) + ) + return response + + def delete(self) -> RuleDeleteSchema: + """ + Delete this specific analytics rule. + + Returns: + RuleDeleteSchema: The schema containing the deletion response. + """ + response: RuleDeleteSchema = self.api_call.delete( + self._endpoint_path, + entity_type=RuleDeleteSchema, + ) + + return response + + @property + def _endpoint_path(self) -> str: + """ + Construct the API endpoint path for this specific analytics rule. + + Returns: + str: The constructed endpoint path. + """ + from typesense.analytics_rules_v1 import AnalyticsRulesV1 + + return "/".join([AnalyticsRulesV1.resource_path, self.rule_id]) + + diff --git a/src/typesense/analytics_rules.py b/src/typesense/analytics_rules.py index 89f748a..2097e0b 100644 --- a/src/typesense/analytics_rules.py +++ b/src/typesense/analytics_rules.py @@ -1,29 +1,4 @@ -""" -This module provides functionality for managing analytics rules in Typesense. - -Classes: - - AnalyticsRules: Handles operations related to analytics rules. - -Methods: - - __init__: Initializes the AnalyticsRules object. - - __getitem__: Retrieves or creates an AnalyticsRule object for a given rule_id. - - create: Creates a new analytics rule. - - upsert: Creates or updates an analytics rule. - - retrieve: Retrieves all analytics rules. - -Attributes: - - resource_path: The API resource path for analytics rules. - -The AnalyticsRules class interacts with the Typesense API to manage analytics rule operations. -It provides methods to create, update, and retrieve analytics rules, as well as access -individual AnalyticsRule objects. - -For more information on analytics, refer to the Analytics & Query Suggestion -[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) - -This module uses type hinting and is compatible with Python 3.11+ as well as earlier -versions through the use of the typing_extensions library. -""" +"""Client for Analytics rules collection operations.""" import sys @@ -32,132 +7,53 @@ else: import typing_extensions as typing -from typesense.analytics_rule import AnalyticsRule from typesense.api_call import ApiCall -from typesense.types.analytics_rule import ( - RuleCreateSchemaForCounters, - RuleCreateSchemaForQueries, - RuleSchemaForCounters, - RuleSchemaForQueries, - RulesRetrieveSchema, +from typesense.types.analytics import ( + AnalyticsRule, + AnalyticsRuleCreate, + AnalyticsRuleUpdate, ) -_RuleParams = typing.Union[ - typing.Dict[str, typing.Union[str, int, bool]], - None, -] - class AnalyticsRules(object): - """ - Class for managing analytics rules in Typesense. - - This class provides methods to interact with analytics rules, including - creating, updating, and retrieving them. - - Attributes: - resource_path (str): The API resource path for analytics rules. - api_call (ApiCall): The API call object for making requests. - rules (Dict[str, AnalyticsRule]): A dictionary of AnalyticsRule objects. - """ - resource_path: typing.Final[str] = "/analytics/rules" - def __init__(self, api_call: ApiCall): - """ - Initialize the AnalyticsRules object. - - Args: - api_call (ApiCall): The API call object for making requests. - """ + def __init__(self, api_call: ApiCall) -> None: self.api_call = api_call - self.rules: typing.Dict[str, AnalyticsRule] = {} - - def __getitem__(self, rule_id: str) -> AnalyticsRule: - """ - Get or create an AnalyticsRule object for a given rule_id. - - Args: - rule_id (str): The ID of the analytics rule. - - Returns: - AnalyticsRule: The AnalyticsRule object for the given ID. - """ - if not self.rules.get(rule_id): - self.rules[rule_id] = AnalyticsRule(self.api_call, rule_id) - return self.rules[rule_id] + self.rules: typing.Dict[str, "AnalyticsRule"] = {} - def create( - self, - rule: typing.Union[RuleCreateSchemaForCounters, RuleCreateSchemaForQueries], - rule_parameters: _RuleParams = None, - ) -> typing.Union[RuleSchemaForCounters, RuleSchemaForQueries]: - """ - Create a new analytics rule. + def __getitem__(self, rule_name: str) -> "AnalyticsRule": + if rule_name not in self.rules: + from typesense.analytics_rule import AnalyticsRule as PerRule - This method can create both counter rules and query rules. + self.rules[rule_name] = PerRule(self.api_call, rule_name) + return typing.cast("AnalyticsRule", self.rules[rule_name]) - Args: - rule (Union[RuleCreateSchemaForCounters, RuleCreateSchemaForQueries]): - The rule schema. Use RuleCreateSchemaForCounters for counter rules - and RuleCreateSchemaForQueries for query rules. - - rule_parameters (_RuleParams, optional): Additional rule parameters. - - Returns: - Union[RuleSchemaForCounters, RuleSchemaForQueries]: - The created rule. Returns RuleSchemaForCounters for counter rules - and RuleSchemaForQueries for query rules. - """ - response: typing.Union[RuleSchemaForCounters, RuleSchemaForQueries] = ( - self.api_call.post( - AnalyticsRules.resource_path, - body=rule, - params=rule_parameters, - as_json=True, - entity_type=typing.Union[ - RuleSchemaForCounters, - RuleSchemaForQueries, - ], - ) - ) - return response - - def upsert( - self, - rule_id: str, - rule: typing.Union[RuleCreateSchemaForQueries, RuleSchemaForCounters], - ) -> typing.Union[RuleSchemaForCounters, RuleCreateSchemaForQueries]: - """ - Create or update an analytics rule. - - Args: - rule_id (str): The ID of the rule to upsert. - rule (Union[RuleCreateSchemaForQueries, RuleSchemaForCounters]): The rule schema. - - Returns: - Union[RuleSchemaForCounters, RuleCreateSchemaForQueries]: The upserted rule. - """ - response = self.api_call.put( - "/".join([AnalyticsRules.resource_path, rule_id]), + def create(self, rule: AnalyticsRuleCreate) -> AnalyticsRule: + response: AnalyticsRule = self.api_call.post( + AnalyticsRules.resource_path, body=rule, - entity_type=typing.Union[RuleSchemaForQueries, RuleSchemaForCounters], - ) - return typing.cast( - typing.Union[RuleSchemaForCounters, RuleCreateSchemaForQueries], - response, + as_json=True, + entity_type=AnalyticsRule, ) + return response - def retrieve(self) -> RulesRetrieveSchema: - """ - Retrieve all analytics rules. - - Returns: - RulesRetrieveSchema: The schema containing all analytics rules. - """ - response: RulesRetrieveSchema = self.api_call.get( + def retrieve(self, *, rule_tag: typing.Union[str, None] = None) -> typing.List[AnalyticsRule]: + params: typing.Dict[str, str] = {} + if rule_tag: + params["rule_tag"] = rule_tag + response: typing.List[AnalyticsRule] = self.api_call.get( AnalyticsRules.resource_path, + params=params if params else None, as_json=True, - entity_type=RulesRetrieveSchema, + entity_type=typing.List[AnalyticsRule], ) return response + + def upsert(self, rule_name: str, update: AnalyticsRuleUpdate) -> AnalyticsRule: + response: AnalyticsRule = self.api_call.put( + "/".join([AnalyticsRules.resource_path, rule_name]), + body=update, + entity_type=AnalyticsRule, + ) + return response \ No newline at end of file diff --git a/src/typesense/analytics_rules_v1.py b/src/typesense/analytics_rules_v1.py new file mode 100644 index 0000000..a850d37 --- /dev/null +++ b/src/typesense/analytics_rules_v1.py @@ -0,0 +1,165 @@ +""" +This module provides functionality for managing analytics rules in Typesense (V1). + +Classes: + - AnalyticsRulesV1: Handles operations related to analytics rules. + +Methods: + - __init__: Initializes the AnalyticsRulesV1 object. + - __getitem__: Retrieves or creates an AnalyticsRuleV1 object for a given rule_id. + - create: Creates a new analytics rule. + - upsert: Creates or updates an analytics rule. + - retrieve: Retrieves all analytics rules. + +Attributes: + - resource_path: The API resource path for analytics rules. + +The AnalyticsRulesV1 class interacts with the Typesense API to manage analytics rule operations. +It provides methods to create, update, and retrieve analytics rules, as well as access +individual AnalyticsRuleV1 objects. + +For more information on analytics, refer to the Analytics & Query Suggestion +[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) + +This module uses type hinting and is compatible with Python 3.11+ as well as earlier +versions through the use of the typing_extensions library. +""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.analytics_rule_v1 import AnalyticsRuleV1 +from typesense.api_call import ApiCall +from typesense.types.analytics_rule_v1 import ( + RuleCreateSchemaForCounters, + RuleCreateSchemaForQueries, + RuleSchemaForCounters, + RuleSchemaForQueries, + RulesRetrieveSchema, +) + +_RuleParams = typing.Union[ + typing.Dict[str, typing.Union[str, int, bool]], + None, +] + + +class AnalyticsRulesV1(object): + """ + Class for managing analytics rules in Typesense (V1). + + This class provides methods to interact with analytics rules, including + creating, updating, and retrieving them. + + Attributes: + resource_path (str): The API resource path for analytics rules. + api_call (ApiCall): The API call object for making requests. + rules (Dict[str, AnalyticsRuleV1]): A dictionary of AnalyticsRuleV1 objects. + """ + + resource_path: typing.Final[str] = "/analytics/rules" + + def __init__(self, api_call: ApiCall): + """ + Initialize the AnalyticsRulesV1 object. + + Args: + api_call (ApiCall): The API call object for making requests. + """ + self.api_call = api_call + self.rules: typing.Dict[str, AnalyticsRuleV1] = {} + + def __getitem__(self, rule_id: str) -> AnalyticsRuleV1: + """ + Get or create an AnalyticsRuleV1 object for a given rule_id. + + Args: + rule_id (str): The ID of the analytics rule. + + Returns: + AnalyticsRuleV1: The AnalyticsRuleV1 object for the given ID. + """ + if not self.rules.get(rule_id): + self.rules[rule_id] = AnalyticsRuleV1(self.api_call, rule_id) + return self.rules[rule_id] + + def create( + self, + rule: typing.Union[RuleCreateSchemaForCounters, RuleCreateSchemaForQueries], + rule_parameters: _RuleParams = None, + ) -> typing.Union[RuleSchemaForCounters, RuleSchemaForQueries]: + """ + Create a new analytics rule. + + This method can create both counter rules and query rules. + + Args: + rule (Union[RuleCreateSchemaForCounters, RuleCreateSchemaForQueries]): + The rule schema. Use RuleCreateSchemaForCounters for counter rules + and RuleCreateSchemaForQueries for query rules. + + rule_parameters (_RuleParams, optional): Additional rule parameters. + + Returns: + Union[RuleSchemaForCounters, RuleSchemaForQueries]: + The created rule. Returns RuleSchemaForCounters for counter rules + and RuleSchemaForQueries for query rules. + """ + response: typing.Union[RuleSchemaForCounters, RuleSchemaForQueries] = ( + self.api_call.post( + AnalyticsRulesV1.resource_path, + body=rule, + params=rule_parameters, + as_json=True, + entity_type=typing.Union[ + RuleSchemaForCounters, + RuleSchemaForQueries, + ], + ) + ) + return response + + def upsert( + self, + rule_id: str, + rule: typing.Union[RuleCreateSchemaForQueries, RuleSchemaForCounters], + ) -> typing.Union[RuleSchemaForCounters, RuleCreateSchemaForQueries]: + """ + Create or update an analytics rule. + + Args: + rule_id (str): The ID of the rule to upsert. + rule (Union[RuleCreateSchemaForQueries, RuleSchemaForCounters]): The rule schema. + + Returns: + Union[RuleSchemaForCounters, RuleCreateSchemaForQueries]: The upserted rule. + """ + response = self.api_call.put( + "/".join([AnalyticsRulesV1.resource_path, rule_id]), + body=rule, + entity_type=typing.Union[RuleSchemaForQueries, RuleSchemaForCounters], + ) + return typing.cast( + typing.Union[RuleSchemaForCounters, RuleCreateSchemaForQueries], + response, + ) + + def retrieve(self) -> RulesRetrieveSchema: + """ + Retrieve all analytics rules. + + Returns: + RulesRetrieveSchema: The schema containing all analytics rules. + """ + response: RulesRetrieveSchema = self.api_call.get( + AnalyticsRulesV1.resource_path, + as_json=True, + entity_type=RulesRetrieveSchema, + ) + return response + + diff --git a/src/typesense/analytics_v1.py b/src/typesense/analytics_v1.py new file mode 100644 index 0000000..b75bfbb --- /dev/null +++ b/src/typesense/analytics_v1.py @@ -0,0 +1,44 @@ +""" +This module provides functionality for managing analytics (V1) in Typesense. + +Classes: + - AnalyticsV1: Handles operations related to analytics, including access to analytics rules. + +Methods: + - __init__: Initializes the AnalyticsV1 object. + +The AnalyticsV1 class serves as an entry point for analytics-related operations in Typesense, +currently providing access to AnalyticsRulesV1. + +For more information on analytics, refer to the Analytics & Query Suggestion +[documentation](https://typesense.org/docs/27.0/api/analytics-query-suggestions.html) + +This module uses type hinting and is compatible with Python 3.11+ as well as earlier +versions through the use of the typing_extensions library. +""" + +from typesense.analytics_rules_v1 import AnalyticsRulesV1 +from typesense.api_call import ApiCall + + +class AnalyticsV1(object): + """ + Class for managing analytics in Typesense (V1). + + This class provides access to analytics-related functionalities, + currently including operations on analytics rules. + + Attributes: + rules (AnalyticsRulesV1): An instance of AnalyticsRulesV1 for managing analytics rules. + """ + + def __init__(self, api_call: ApiCall) -> None: + """ + Initialize the AnalyticsV1 object. + + Args: + api_call (ApiCall): The API call object for making requests. + """ + self.rules = AnalyticsRulesV1(api_call) + + diff --git a/src/typesense/client.py b/src/typesense/client.py index f60acd0..d5d7dee 100644 --- a/src/typesense/client.py +++ b/src/typesense/client.py @@ -36,6 +36,7 @@ import typing_extensions as typing from typesense.aliases import Aliases +from typesense.analytics_v1 import AnalyticsV1 from typesense.analytics import Analytics from typesense.api_call import ApiCall from typesense.collection import Collection @@ -70,7 +71,8 @@ class Client: multi_search (MultiSearch): Instance for performing multi-search operations. keys (Keys): Instance for managing API keys. aliases (Aliases): Instance for managing collection aliases. - analytics (Analytics): Instance for analytics operations. + analyticsV1 (AnalyticsV1): Instance for analytics operations (V1). + analytics (AnalyticsV30): Instance for analytics operations (v30). stemming (Stemming): Instance for stemming dictionary operations. operations (Operations): Instance for various Typesense operations. debug (Debug): Instance for debug operations. @@ -101,6 +103,7 @@ def __init__(self, config_dict: ConfigDict) -> None: self.multi_search = MultiSearch(self.api_call) self.keys = Keys(self.api_call) self.aliases = Aliases(self.api_call) + self.analyticsV1 = AnalyticsV1(self.api_call) self.analytics = Analytics(self.api_call) self.stemming = Stemming(self.api_call) self.operations = Operations(self.api_call) diff --git a/src/typesense/types/analytics.py b/src/typesense/types/analytics.py new file mode 100644 index 0000000..540c8b4 --- /dev/null +++ b/src/typesense/types/analytics.py @@ -0,0 +1,84 @@ +"""Types for Analytics endpoints and Analytics Rules.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + + +class AnalyticsEvent(typing.TypedDict): + """Schema for an analytics event to be created.""" + + name: str + event_type: str + data: typing.Dict[str, typing.Any] + + +class AnalyticsEventCreateResponse(typing.TypedDict): + """Response schema for creating an analytics event and for flush.""" + + ok: bool + + +class _AnalyticsEventItem(typing.TypedDict, total=False): + name: str + event_type: str + collection: str + timestamp: int + user_id: str + doc_id: str + doc_ids: typing.List[str] + query: str + + +class AnalyticsEventsResponse(typing.TypedDict): + """Response schema for retrieving analytics events.""" + + events: typing.List[_AnalyticsEventItem] + + +class AnalyticsStatus(typing.TypedDict, total=False): + """Response schema for analytics status.""" + + popular_prefix_queries: int + nohits_prefix_queries: int + log_prefix_queries: int + query_log_events: int + query_counter_events: int + doc_log_events: int + doc_counter_events: int + + +# Rules + +class AnalyticsRuleParams(typing.TypedDict, total=False): + destination_collection: str + limit: int + capture_search_requests: bool + meta_fields: typing.List[str] + expand_query: bool + counter_field: str + weight: int + + +class AnalyticsRuleCreate(typing.TypedDict): + name: str + type: str + collection: str + event_type: str + params: AnalyticsRuleParams + rule_tag: typing.NotRequired[str] + + +class AnalyticsRuleUpdate(typing.TypedDict, total=False): + name: str + rule_tag: str + params: AnalyticsRuleParams + + +class AnalyticsRule(AnalyticsRuleCreate, total=False): + pass + + diff --git a/src/typesense/types/analytics_rule.py b/src/typesense/types/analytics_rule_v1.py similarity index 98% rename from src/typesense/types/analytics_rule.py rename to src/typesense/types/analytics_rule_v1.py index af261bc..3f76046 100644 --- a/src/typesense/types/analytics_rule.py +++ b/src/typesense/types/analytics_rule_v1.py @@ -1,4 +1,4 @@ -"""Analytics Rule types for Typesense Python Client.""" +"""Analytics Rule V1 types for Typesense Python Client.""" import sys @@ -201,3 +201,5 @@ class RulesRetrieveSchema(typing.TypedDict): """ rules: typing.List[typing.Union[RuleSchemaForQueries, RuleSchemaForCounters]] + + diff --git a/src/typesense/types/collection.py b/src/typesense/types/collection.py index 9e8a397..2cb0d28 100644 --- a/src/typesense/types/collection.py +++ b/src/typesense/types/collection.py @@ -180,6 +180,7 @@ class CollectionCreateSchema(typing.TypedDict): token_separators: typing.NotRequired[typing.List[str]] enable_nested_fields: typing.NotRequired[bool] voice_query_model: typing.NotRequired[VoiceQueryModelSchema] + synonym_sets: typing.NotRequired[typing.List[typing.List[str]]] class CollectionSchema(CollectionCreateSchema): diff --git a/tests/analytics_events_test.py b/tests/analytics_events_test.py new file mode 100644 index 0000000..81af690 --- /dev/null +++ b/tests/analytics_events_test.py @@ -0,0 +1,140 @@ +"""Tests for Analytics events endpoints (client.analytics.events).""" +from __future__ import annotations + +import pytest + +from tests.utils.version import is_v30_or_above +from typesense.client import Client +import requests_mock + +from typesense.types.analytics import AnalyticsEvent + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Run analytics events tests only on v30+", +) + + +def test_actual_create_event(actual_client: Client, delete_all: None, create_collection: None, delete_all_analytics_rules: None) -> None: + actual_client.analytics.rules.create( + { + "name": "company_analytics_rule", + "type": "log", + "collection": "companies", + "event_type": "click", + "params": {}, + } + ) + event: AnalyticsEvent = { + "name": "company_analytics_rule", + "event_type": "query", + "data": { + "user_id": "user-1", + "doc_id": "apple", + }, + } + resp = actual_client.analytics.events.create(event) + assert resp["ok"] is True + actual_client.analytics.rules["company_analytics_rule"].delete() + + +def test_create_event(fake_client: Client) -> None: + event: AnalyticsEvent = { + "name": "company_analytics_rule", + "event_type": "query", + "data": {"user_id": "user-1", "q": "apple"}, + } + with requests_mock.Mocker() as mock: + mock.post("http://nearest:8108/analytics/events", json={"ok": True}) + resp = fake_client.analytics.events.create(event) + assert resp["ok"] is True + + +def test_status(actual_client: Client, delete_all: None) -> None: + status = actual_client.analytics.events.status() + assert isinstance(status, dict) + + +def test_retrieve_events(actual_client: Client, delete_all: None, delete_all_analytics_rules: None) -> None: + actual_client.analytics.rules.create( + { + "name": "company_analytics_rule", + "type": "log", + "collection": "companies", + "event_type": "click", + "params": {}, + } + ) + event: AnalyticsEvent = { + "name": "company_analytics_rule", + "event_type": "query", + "data": { + "user_id": "user-1", + "doc_id": "apple", + }, + } + resp = actual_client.analytics.events.create(event) + assert resp["ok"] is True + result = actual_client.analytics.events.retrieve( + user_id="user-1", + name="company_analytics_rule", + n=10, + ) + assert "events" in result + + + +def test_retrieve_events(fake_client: Client) -> None: + with requests_mock.Mocker() as mock: + mock.get( + "http://nearest:8108/analytics/events", + json={"events": [{"name": "company_analytics_rule"}]}, + ) + result = fake_client.analytics.events.retrieve( + user_id="user-1", name="company_analytics_rule", n=10 + ) + assert "events" in result + +def test_acutal_retrieve_events(actual_client: Client, delete_all: None, create_collection: None, delete_all_analytics_rules: None) -> None: + actual_client.analytics.rules.create( + { + "name": "company_analytics_rule", + "type": "log", + "collection": "companies", + "event_type": "click", + "params": {}, + } + ) + event: AnalyticsEvent = { + "name": "company_analytics_rule", + "event_type": "query", + "data": { + "user_id": "user-1", + "doc_id": "apple", + }, + } + resp = actual_client.analytics.events.create(event) + assert resp["ok"] is True + result = actual_client.analytics.events.retrieve( + user_id="user-1", name="company_analytics_rule", n=10 + ) + assert "events" in result + +def test_acutal_flush(actual_client: Client, delete_all: None) -> None: + resp = actual_client.analytics.events.flush() + assert resp["ok"] in [True, False] + + +def test_flush(fake_client: Client) -> None: + with requests_mock.Mocker() as mock: + mock.post("http://nearest:8108/analytics/flush", json={"ok": True}) + resp = fake_client.analytics.events.flush() + assert resp["ok"] is True + + diff --git a/tests/analytics_rule_test.py b/tests/analytics_rule_test.py index 4141c55..68b9122 100644 --- a/tests/analytics_rule_test.py +++ b/tests/analytics_rule_test.py @@ -1,120 +1,67 @@ -"""Tests for the AnalyticsRule class.""" - +"""Unit tests for per-rule AnalyticsRule operations.""" from __future__ import annotations +import pytest import requests_mock -from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from tests.utils.version import is_v30_or_above +from typesense.client import Client from typesense.analytics_rule import AnalyticsRule from typesense.analytics_rules import AnalyticsRules -from typesense.api_call import ApiCall -from typesense.types.analytics_rule import RuleDeleteSchema, RuleSchemaForQueries - - -def test_init(fake_api_call: ApiCall) -> None: - """Test that the AnalyticsRule object is initialized correctly.""" - analytics_rule = AnalyticsRule(fake_api_call, "company_analytics_rule") - assert analytics_rule.rule_id == "company_analytics_rule" - assert_match_object(analytics_rule.api_call, fake_api_call) - assert_object_lists_match( - analytics_rule.api_call.node_manager.nodes, - fake_api_call.node_manager.nodes, - ) - assert_match_object( - analytics_rule.api_call.config.nearest_node, - fake_api_call.config.nearest_node, - ) - assert ( - analytics_rule._endpoint_path # noqa: WPS437 - == "/analytics/rules/company_analytics_rule" - ) +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Run analytics tests only on v30+", +) -def test_retrieve(fake_analytics_rule: AnalyticsRule) -> None: - """Test that the AnalyticsRule object can retrieve an analytics_rule.""" - json_response: RuleSchemaForQueries = { - "name": "company_analytics_rule", - "params": { - "destination": { - "collection": "companies_queries", - }, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - } +def test_rule_retrieve(fake_api_call) -> None: + rule = AnalyticsRule(fake_api_call, "company_analytics_rule") + expected = {"name": "company_analytics_rule"} with requests_mock.Mocker() as mock: mock.get( - "/analytics/rules/company_analytics_rule", - json=json_response, - ) - - response = fake_analytics_rule.retrieve() - - assert len(mock.request_history) == 1 - assert mock.request_history[0].method == "GET" - assert ( - mock.request_history[0].url - == "http://nearest:8108/analytics/rules/company_analytics_rule" + "http://nearest:8108/analytics/rules/company_analytics_rule", + json=expected, ) - assert response == json_response + resp = rule.retrieve() + assert resp == expected -def test_delete(fake_analytics_rule: AnalyticsRule) -> None: - """Test that the AnalyticsRule object can delete an analytics_rule.""" - json_response: RuleDeleteSchema = { - "name": "company_analytics_rule", - } +def test_rule_delete(fake_api_call) -> None: + rule = AnalyticsRule(fake_api_call, "company_analytics_rule") + expected = {"name": "company_analytics_rule"} with requests_mock.Mocker() as mock: mock.delete( - "/analytics/rules/company_analytics_rule", - json=json_response, + "http://nearest:8108/analytics/rules/company_analytics_rule", + json=expected, ) + resp = rule.delete() + assert resp == expected - response = fake_analytics_rule.delete() - assert len(mock.request_history) == 1 - assert mock.request_history[0].method == "DELETE" - assert ( - mock.request_history[0].url - == "http://nearest:8108/analytics/rules/company_analytics_rule" - ) - assert response == json_response - - -def test_actual_retrieve( +def test_actual_rule_retrieve( actual_analytics_rules: AnalyticsRules, delete_all: None, delete_all_analytics_rules: None, create_analytics_rule: None, ) -> None: - """Test that the AnalyticsRule object can retrieve a rule from Typesense Server.""" - response = actual_analytics_rules["company_analytics_rule"].retrieve() + resp = actual_analytics_rules["company_analytics_rule"].retrieve() + assert resp["name"] == "company_analytics_rule" - expected: RuleSchemaForQueries = { - "name": "company_analytics_rule", - "params": { - "destination": {"collection": "companies_queries"}, - "limit": 1000, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - } - assert response == expected - - -def test_actual_delete( +def test_actual_rule_delete( actual_analytics_rules: AnalyticsRules, delete_all: None, delete_all_analytics_rules: None, create_analytics_rule: None, ) -> None: - """Test that the AnalyticsRule object can delete a rule from Typesense Server.""" - response = actual_analytics_rules["company_analytics_rule"].delete() + resp = actual_analytics_rules["company_analytics_rule"].delete() + assert resp["name"] == "company_analytics_rule" + - expected: RuleDeleteSchema = { - "name": "company_analytics_rule", - } - assert response == expected diff --git a/tests/analytics_rule_v1_test.py b/tests/analytics_rule_v1_test.py new file mode 100644 index 0000000..8cc970b --- /dev/null +++ b/tests/analytics_rule_v1_test.py @@ -0,0 +1,129 @@ +"""Tests for the AnalyticsRuleV1 class.""" +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from tests.utils.version import is_v30_or_above +from typesense.client import Client +from typesense.analytics_rule_v1 import AnalyticsRuleV1 +from typesense.analytics_rules_v1 import AnalyticsRulesV1 +from typesense.api_call import ApiCall +from typesense.types.analytics_rule_v1 import RuleDeleteSchema, RuleSchemaForQueries + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_init(fake_api_call: ApiCall) -> None: + """Test that the AnalyticsRuleV1 object is initialized correctly.""" + analytics_rule = AnalyticsRuleV1(fake_api_call, "company_analytics_rule") + + assert analytics_rule.rule_id == "company_analytics_rule" + assert_match_object(analytics_rule.api_call, fake_api_call) + assert_object_lists_match( + analytics_rule.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + assert_match_object( + analytics_rule.api_call.config.nearest_node, + fake_api_call.config.nearest_node, + ) + assert ( + analytics_rule._endpoint_path # noqa: WPS437 + == "/analytics/rules/company_analytics_rule" + ) + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_retrieve(fake_analytics_rule: AnalyticsRuleV1) -> None: + """Test that the AnalyticsRuleV1 object can retrieve an analytics_rule.""" + json_response: RuleSchemaForQueries = { + "name": "company_analytics_rule", + "params": { + "destination": { + "collection": "companies_queries", + }, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + } + + with requests_mock.Mocker() as mock: + mock.get( + "/analytics/rules/company_analytics_rule", + json=json_response, + ) + + response = fake_analytics_rule.retrieve() + + assert len(mock.request_history) == 1 + assert mock.request_history[0].method == "GET" + assert ( + mock.request_history[0].url + == "http://nearest:8108/analytics/rules/company_analytics_rule" + ) + assert response == json_response + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_delete(fake_analytics_rule: AnalyticsRuleV1) -> None: + """Test that the AnalyticsRuleV1 object can delete an analytics_rule.""" + json_response: RuleDeleteSchema = { + "name": "company_analytics_rule", + } + with requests_mock.Mocker() as mock: + mock.delete( + "/analytics/rules/company_analytics_rule", + json=json_response, + ) + + response = fake_analytics_rule.delete() + + assert len(mock.request_history) == 1 + assert mock.request_history[0].method == "DELETE" + assert ( + mock.request_history[0].url + == "http://nearest:8108/analytics/rules/company_analytics_rule" + ) + assert response == json_response + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_actual_retrieve( + actual_analytics_rules: AnalyticsRulesV1, + delete_all: None, + delete_all_analytics_rules_v1: None, + create_analytics_rule_v1: None, +) -> None: + """Test that the AnalyticsRuleV1 object can retrieve a rule from Typesense Server.""" + response = actual_analytics_rules["company_analytics_rule"].retrieve() + + expected: RuleSchemaForQueries = { + "name": "company_analytics_rule", + "params": { + "destination": {"collection": "companies_queries"}, + "limit": 1000, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + } + + assert response == expected + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_actual_delete( + actual_analytics_rules: AnalyticsRulesV1, + delete_all: None, + delete_all_analytics_rules_v1: None, + create_analytics_rule_v1: None, +) -> None: + """Test that the AnalyticsRuleV1 object can delete a rule from Typesense Server.""" + response = actual_analytics_rules["company_analytics_rule"].delete() + + expected: RuleDeleteSchema = { + "name": "company_analytics_rule", + } + assert response == expected + + diff --git a/tests/analytics_rules_test.py b/tests/analytics_rules_test.py index edad1d8..ef67bb6 100644 --- a/tests/analytics_rules_test.py +++ b/tests/analytics_rules_test.py @@ -1,141 +1,87 @@ -"""Tests for the AnalyticsRules class.""" - +"""Tests for v30 Analytics Rules endpoints (client.analytics.rules).""" from __future__ import annotations +import pytest import requests_mock -from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from tests.utils.version import is_v30_or_above +from typesense.client import Client from typesense.analytics_rules import AnalyticsRules -from typesense.api_call import ApiCall -from typesense.types.analytics_rule import ( - RuleCreateSchemaForQueries, - RulesRetrieveSchema, +from typesense.analytics_rule import AnalyticsRule +from typesense.types.analytics import AnalyticsRuleCreate + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Run v30 analytics tests only on v30+", ) -def test_init(fake_api_call: ApiCall) -> None: - """Test that the AnalyticsRules object is initialized correctly.""" - analytics_rules = AnalyticsRules(fake_api_call) - - assert_match_object(analytics_rules.api_call, fake_api_call) - assert_object_lists_match( - analytics_rules.api_call.node_manager.nodes, - fake_api_call.node_manager.nodes, - ) - assert_match_object( - analytics_rules.api_call.config.nearest_node, - fake_api_call.config.nearest_node, - ) - - assert not analytics_rules.rules - - -def test_get_missing_analytics_rule(fake_analytics_rules: AnalyticsRules) -> None: - """Test that the AnalyticsRules object can get a missing analytics_rule.""" - analytics_rule = fake_analytics_rules["company_analytics_rule"] - - assert analytics_rule.rule_id == "company_analytics_rule" - assert_match_object(analytics_rule.api_call, fake_analytics_rules.api_call) - assert_object_lists_match( - analytics_rule.api_call.node_manager.nodes, - fake_analytics_rules.api_call.node_manager.nodes, - ) - assert_match_object( - analytics_rule.api_call.config.nearest_node, - fake_analytics_rules.api_call.config.nearest_node, - ) - assert ( - analytics_rule._endpoint_path # noqa: WPS437 - == "/analytics/rules/company_analytics_rule" - ) - - -def test_get_existing_analytics_rule(fake_analytics_rules: AnalyticsRules) -> None: - """Test that the AnalyticsRules object can get an existing analytics_rule.""" - analytics_rule = fake_analytics_rules["company_analytics_rule"] - fetched_analytics_rule = fake_analytics_rules["company_analytics_rule"] +def test_rules_init(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) + assert rules.rules == {} - assert len(fake_analytics_rules.rules) == 1 - assert analytics_rule is fetched_analytics_rule +def test_rule_getitem(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) + rule = rules["company_analytics_rule"] + assert isinstance(rule, AnalyticsRule) + assert rule._endpoint_path == "/analytics/rules/company_analytics_rule" -def test_retrieve(fake_analytics_rules: AnalyticsRules) -> None: - """Test that the AnalyticsRules object can retrieve analytics_rules.""" - json_response: RulesRetrieveSchema = { - "rules": [ - { - "name": "company_analytics_rule", - "params": { - "destination": { - "collection": "companies_queries", - }, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - }, - ], +def test_rules_create(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) + body: AnalyticsRuleCreate = { + "name": "company_analytics_rule", + "type": "popular_queries", + "collection": "companies", + "event_type": "query", + "params": {"destination_collection": "companies_queries", "limit": 1000}, } + with requests_mock.Mocker() as mock: + mock.post("http://nearest:8108/analytics/rules", json=body) + resp = rules.create(body) + assert resp == body + +def test_rules_retrieve_with_tag(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) with requests_mock.Mocker() as mock: mock.get( - "http://nearest:8108/analytics/rules", - json=json_response, + "http://nearest:8108/analytics/rules?rule_tag=homepage", + json=[{"name": "rule1", "rule_tag": "homepage"}], ) + resp = rules.retrieve(rule_tag="homepage") + assert isinstance(resp, list) + assert resp[0]["rule_tag"] == "homepage" - response = fake_analytics_rules.retrieve() - - assert len(response) == 1 - assert response["rules"][0] == json_response.get("rules")[0] - assert response == json_response +def test_rules_upsert(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) + with requests_mock.Mocker() as mock: + mock.put( + "http://nearest:8108/analytics/rules/company_analytics_rule", + json={"name": "company_analytics_rule"}, + ) + resp = rules.upsert("company_analytics_rule", {"params": {}}) + assert resp["name"] == "company_analytics_rule" -def test_create(fake_analytics_rules: AnalyticsRules) -> None: - """Test that the AnalyticsRules object can create a analytics_rule.""" - json_response: RuleCreateSchemaForQueries = { - "name": "company_analytics_rule", - "params": { - "destination": { - "collection": "companies_queries", - }, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - } +def test_rules_retrieve(fake_api_call) -> None: + rules = AnalyticsRules(fake_api_call) with requests_mock.Mocker() as mock: - mock.post( + mock.get( "http://nearest:8108/analytics/rules", - json=json_response, + json=[{"name": "company_analytics_rule"}], ) - - fake_analytics_rules.create( - rule={ - "params": { - "destination": { - "collection": "companies_queries", - }, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - "name": "company_analytics_rule", - }, - ) - - assert mock.call_count == 1 - assert mock.called is True - assert mock.last_request.method == "POST" - assert mock.last_request.url == "http://nearest:8108/analytics/rules" - assert mock.last_request.json() == { - "params": { - "destination": { - "collection": "companies_queries", - }, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - "name": "company_analytics_rule", - } + resp = rules.retrieve() + assert isinstance(resp, list) + assert resp[0]["name"] == "company_analytics_rule" def test_actual_create( @@ -145,28 +91,16 @@ def test_actual_create( create_collection: None, create_query_collection: None, ) -> None: - """Test that the AnalyticsRules object can create an analytics_rule on Typesense Server.""" - response = actual_analytics_rules.create( - rule={ - "name": "company_analytics_rule", - "type": "nohits_queries", - "params": { - "source": { - "collections": ["companies"], - }, - "destination": {"collection": "companies_queries"}, - }, - }, - ) - - assert response == { + body: AnalyticsRuleCreate = { "name": "company_analytics_rule", "type": "nohits_queries", - "params": { - "source": {"collections": ["companies"]}, - "destination": {"collection": "companies_queries"}, - }, + "collection": "companies", + "event_type": "query", + "params": {"destination_collection": "companies_queries", "limit": 1000}, } + resp = actual_analytics_rules.create(rule=body) + assert resp["name"] == "company_analytics_rule" + assert resp["params"]["destination_collection"] == "companies_queries" def test_actual_update( @@ -175,28 +109,16 @@ def test_actual_update( delete_all_analytics_rules: None, create_analytics_rule: None, ) -> None: - """Test that the AnalyticsRules object can update an analytics_rule on Typesense Server.""" - response = actual_analytics_rules.upsert( + resp = actual_analytics_rules.upsert( "company_analytics_rule", { - "type": "popular_queries", "params": { - "source": { - "collections": ["companies"], - }, - "destination": {"collection": "companies_queries"}, + "destination_collection": "companies_queries", + "limit": 500, }, }, ) - - assert response == { - "name": "company_analytics_rule", - "type": "popular_queries", - "params": { - "source": {"collections": ["companies"]}, - "destination": {"collection": "companies_queries"}, - }, - } + assert resp["name"] == "company_analytics_rule" def test_actual_retrieve( @@ -205,18 +127,8 @@ def test_actual_retrieve( delete_all_analytics_rules: None, create_analytics_rule: None, ) -> None: - """Test that the AnalyticsRules object can retrieve the rules from Typesense Server.""" - response = actual_analytics_rules.retrieve() - assert len(response["rules"]) == 1 - assert_match_object( - response["rules"][0], - { - "name": "company_analytics_rule", - "params": { - "destination": {"collection": "companies_queries"}, - "limit": 1000, - "source": {"collections": ["companies"]}, - }, - "type": "nohits_queries", - }, - ) + rules = actual_analytics_rules.retrieve() + assert isinstance(rules, list) + assert any(r.get("name") == "company_analytics_rule" for r in rules) + + diff --git a/tests/analytics_rules_v1_test.py b/tests/analytics_rules_v1_test.py new file mode 100644 index 0000000..674ac34 --- /dev/null +++ b/tests/analytics_rules_v1_test.py @@ -0,0 +1,234 @@ +"""Tests for the AnalyticsRulesV1 class.""" +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from tests.utils.version import is_v30_or_above +from typesense.client import Client +from typesense.analytics_rules_v1 import AnalyticsRulesV1 +from typesense.api_call import ApiCall +from typesense.types.analytics_rule_v1 import ( + RuleCreateSchemaForQueries, + RulesRetrieveSchema, +) + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_init(fake_api_call: ApiCall) -> None: + """Test that the AnalyticsRulesV1 object is initialized correctly.""" + analytics_rules = AnalyticsRulesV1(fake_api_call) + + assert_match_object(analytics_rules.api_call, fake_api_call) + assert_object_lists_match( + analytics_rules.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + assert_match_object( + analytics_rules.api_call.config.nearest_node, + fake_api_call.config.nearest_node, + ) + + assert not analytics_rules.rules + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_get_missing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> None: + """Test that the AnalyticsRulesV1 object can get a missing analytics_rule.""" + analytics_rule = fake_analytics_rules["company_analytics_rule"] + + assert analytics_rule.rule_id == "company_analytics_rule" + assert_match_object(analytics_rule.api_call, fake_analytics_rules.api_call) + assert_object_lists_match( + analytics_rule.api_call.node_manager.nodes, + fake_analytics_rules.api_call.node_manager.nodes, + ) + assert_match_object( + analytics_rule.api_call.config.nearest_node, + fake_analytics_rules.api_call.config.nearest_node, + ) + assert ( + analytics_rule._endpoint_path # noqa: WPS437 + == "/analytics/rules/company_analytics_rule" + ) + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_get_existing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> None: + """Test that the AnalyticsRulesV1 object can get an existing analytics_rule.""" + analytics_rule = fake_analytics_rules["company_analytics_rule"] + fetched_analytics_rule = fake_analytics_rules["company_analytics_rule"] + + assert len(fake_analytics_rules.rules) == 1 + + assert analytics_rule is fetched_analytics_rule + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_retrieve(fake_analytics_rules: AnalyticsRulesV1) -> None: + """Test that the AnalyticsRulesV1 object can retrieve analytics_rules.""" + json_response: RulesRetrieveSchema = { + "rules": [ + { + "name": "company_analytics_rule", + "params": { + "destination": { + "collection": "companies_queries", + }, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + }, + ], + } + + with requests_mock.Mocker() as mock: + mock.get( + "http://nearest:8108/analytics/rules", + json=json_response, + ) + + response = fake_analytics_rules.retrieve() + + assert len(response) == 1 + assert response["rules"][0] == json_response.get("rules")[0] + assert response == json_response + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_create(fake_analytics_rules: AnalyticsRulesV1) -> None: + """Test that the AnalyticsRulesV1 object can create a analytics_rule.""" + json_response: RuleCreateSchemaForQueries = { + "name": "company_analytics_rule", + "params": { + "destination": { + "collection": "companies_queries", + }, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + } + + with requests_mock.Mocker() as mock: + mock.post( + "http://nearest:8108/analytics/rules", + json=json_response, + ) + + fake_analytics_rules.create( + rule={ + "params": { + "destination": { + "collection": "companies_queries", + }, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + "name": "company_analytics_rule", + }, + ) + + assert mock.call_count == 1 + assert mock.called is True + assert mock.last_request.method == "POST" + assert mock.last_request.url == "http://nearest:8108/analytics/rules" + assert mock.last_request.json() == { + "params": { + "destination": { + "collection": "companies_queries", + }, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + "name": "company_analytics_rule", + } + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_actual_create( + actual_analytics_rules: AnalyticsRulesV1, + delete_all: None, + delete_all_analytics_rules_v1: None, + create_collection: None, + create_query_collection: None, +) -> None: + """Test that the AnalyticsRulesV1 object can create an analytics_rule on Typesense Server.""" + response = actual_analytics_rules.create( + rule={ + "name": "company_analytics_rule", + "type": "nohits_queries", + "params": { + "source": { + "collections": ["companies"], + }, + "destination": {"collection": "companies_queries"}, + }, + }, + ) + + assert response == { + "name": "company_analytics_rule", + "type": "nohits_queries", + "params": { + "source": {"collections": ["companies"]}, + "destination": {"collection": "companies_queries"}, + }, + } + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_actual_update( + actual_analytics_rules: AnalyticsRulesV1, + delete_all: None, + delete_all_analytics_rules_v1: None, + create_analytics_rule_v1: None, +) -> None: + """Test that the AnalyticsRulesV1 object can update an analytics_rule on Typesense Server.""" + response = actual_analytics_rules.upsert( + "company_analytics_rule", + { + "type": "popular_queries", + "params": { + "source": { + "collections": ["companies"], + }, + "destination": {"collection": "companies_queries"}, + }, + }, + ) + + assert response == { + "name": "company_analytics_rule", + "type": "popular_queries", + "params": { + "source": {"collections": ["companies"]}, + "destination": {"collection": "companies_queries"}, + }, + } + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_actual_retrieve( + actual_analytics_rules: AnalyticsRulesV1, + delete_all: None, + delete_all_analytics_rules_v1: None, + create_analytics_rule_v1: None, +) -> None: + """Test that the AnalyticsRulesV1 object can retrieve the rules from Typesense Server.""" + response = actual_analytics_rules.retrieve() + assert len(response["rules"]) == 1 + assert_match_object( + response["rules"][0], + { + "name": "company_analytics_rule", + "params": { + "destination": {"collection": "companies_queries"}, + "limit": 1000, + "source": {"collections": ["companies"]}, + }, + "type": "nohits_queries", + }, + ) + + diff --git a/tests/analytics_test.py b/tests/analytics_test.py index e2e4441..5d9e56d 100644 --- a/tests/analytics_test.py +++ b/tests/analytics_test.py @@ -1,12 +1,15 @@ -"""Tests for the Analytics class.""" - +"""Tests for the AnalyticsV1 class.""" +import pytest +from tests.utils.version import is_v30_or_above +from typesense.client import Client from tests.utils.object_assertions import assert_match_object, assert_object_lists_match from typesense.analytics import Analytics from typesense.api_call import ApiCall +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_init(fake_api_call: ApiCall) -> None: - """Test that the Analytics object is initialized correctly.""" + """Test that the AnalyticsV1 object is initialized correctly.""" analytics = Analytics(fake_api_call) assert_match_object(analytics.rules.api_call, fake_api_call) diff --git a/tests/analytics_v1_test.py b/tests/analytics_v1_test.py new file mode 100644 index 0000000..50b9339 --- /dev/null +++ b/tests/analytics_v1_test.py @@ -0,0 +1,27 @@ +"""Tests for the AnalyticsV1 class.""" +import pytest +from tests.utils.version import is_v30_or_above +from typesense.client import Client +from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from typesense.analytics_v1 import AnalyticsV1 +from typesense.api_call import ApiCall + + +@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +def test_init(fake_api_call: ApiCall) -> None: + """Test that the AnalyticsV1 object is initialized correctly.""" + analytics = AnalyticsV1(fake_api_call) + + assert_match_object(analytics.rules.api_call, fake_api_call) + assert_object_lists_match( + analytics.rules.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + assert_match_object( + analytics.rules.api_call.config.nearest_node, + fake_api_call.config.nearest_node, + ) + + assert not analytics.rules.rules + + diff --git a/tests/client_test.py b/tests/client_test.py index b25f9e9..3997939 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -27,9 +27,9 @@ def test_client_init(fake_config_dict: ConfigDict) -> None: assert fake_client.keys.keys is not None assert fake_client.aliases assert fake_client.aliases.aliases is not None - assert fake_client.analytics - assert fake_client.analytics.rules - assert fake_client.analytics.rules.rules is not None + assert fake_client.analyticsV1 + assert fake_client.analyticsV1.rules + assert fake_client.analyticsV1.rules.rules is not None assert fake_client.operations assert fake_client.debug diff --git a/tests/collection_test.py b/tests/collection_test.py index 33c7837..49e6422 100644 --- a/tests/collection_test.py +++ b/tests/collection_test.py @@ -218,6 +218,7 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] } response.pop("created_at") diff --git a/tests/collections_test.py b/tests/collections_test.py index 84971bd..a68b468 100644 --- a/tests/collections_test.py +++ b/tests/collections_test.py @@ -86,6 +86,7 @@ def test_retrieve(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] }, { "created_at": 1619711488, @@ -105,6 +106,7 @@ def test_retrieve(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] }, ] with requests_mock.Mocker() as mock: @@ -138,6 +140,7 @@ def test_create(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] } with requests_mock.Mocker() as mock: @@ -220,6 +223,7 @@ def test_actual_create(actual_collections: Collections, delete_all: None) -> Non "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] } response = actual_collections.create( @@ -288,6 +292,7 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [] }, ] diff --git a/tests/fixtures/analytics_rule_fixtures.py b/tests/fixtures/analytics_fixtures.py similarity index 75% rename from tests/fixtures/analytics_rule_fixtures.py rename to tests/fixtures/analytics_fixtures.py index 2f92008..d0f7715 100644 --- a/tests/fixtures/analytics_rule_fixtures.py +++ b/tests/fixtures/analytics_fixtures.py @@ -1,4 +1,4 @@ -"""Fixtures for the Analytics Rules tests.""" +"""Fixtures for Analytics (current) tests.""" import pytest import requests @@ -10,19 +10,18 @@ @pytest.fixture(scope="function", name="delete_all_analytics_rules") def clear_typesense_analytics_rules() -> None: - """Remove all analytics_rules from the Typesense server.""" + """Remove all analytics rules from the Typesense server.""" url = "http://localhost:8108/analytics/rules" headers = {"X-TYPESENSE-API-KEY": "xyz"} - # Get the list of rules response = requests.get(url, headers=headers, timeout=3) response.raise_for_status() - analytics_rules = response.json() + rules = response.json() - # Delete each analytics_rule - for analytics_rule_set in analytics_rules["rules"]: - analytics_rule_id = analytics_rule_set.get("name") - delete_url = f"{url}/{analytics_rule_id}" + # v30 returns a list of rule objects + for rule in rules: + rule_name = rule.get("name") + delete_url = f"{url}/{rule_name}" delete_response = requests.delete(delete_url, headers=headers, timeout=3) delete_response.raise_for_status() @@ -32,17 +31,17 @@ def create_analytics_rule_fixture( create_collection: None, create_query_collection: None, ) -> None: - """Create a collection in the Typesense server.""" + """Create an analytics rule in the Typesense server.""" url = "http://localhost:8108/analytics/rules" headers = {"X-TYPESENSE-API-KEY": "xyz"} analytics_rule_data = { "name": "company_analytics_rule", "type": "nohits_queries", + "collection": "companies", + "event_type": "query", "params": { - "source": { - "collections": ["companies"], - }, - "destination": {"collection": "companies_queries"}, + "destination_collection": "companies_queries", + "limit": 1000, }, } @@ -52,22 +51,21 @@ def create_analytics_rule_fixture( @pytest.fixture(scope="function", name="fake_analytics_rules") def fake_analytics_rules_fixture(fake_api_call: ApiCall) -> AnalyticsRules: - """Return a AnalyticsRule object with test values.""" + """Return an AnalyticsRules object with test values.""" return AnalyticsRules(fake_api_call) @pytest.fixture(scope="function", name="actual_analytics_rules") def actual_analytics_rules_fixture(actual_api_call: ApiCall) -> AnalyticsRules: - """Return a AnalyticsRules object using a real API.""" + """Return an AnalyticsRules object using a real API.""" return AnalyticsRules(actual_api_call) @pytest.fixture(scope="function", name="fake_analytics_rule") def fake_analytics_rule_fixture(fake_api_call: ApiCall) -> AnalyticsRule: - """Return a AnalyticsRule object with test values.""" + """Return an AnalyticsRule object with test values.""" return AnalyticsRule(fake_api_call, "company_analytics_rule") - @pytest.fixture(scope="function", name="create_query_collection") def create_query_collection_fixture() -> None: """Create a query collection for analytics rules in the Typesense server.""" @@ -93,4 +91,4 @@ def create_query_collection_fixture() -> None: json=query_collection_data, timeout=3, ) - response.raise_for_status() + response.raise_for_status() \ No newline at end of file diff --git a/tests/fixtures/analytics_rule_v1_fixtures.py b/tests/fixtures/analytics_rule_v1_fixtures.py new file mode 100644 index 0000000..44994eb --- /dev/null +++ b/tests/fixtures/analytics_rule_v1_fixtures.py @@ -0,0 +1,70 @@ +"""Fixtures for the Analytics Rules V1 tests.""" + +import pytest +import requests + +from typesense.analytics_rule_v1 import AnalyticsRuleV1 +from typesense.analytics_rules_v1 import AnalyticsRulesV1 +from typesense.api_call import ApiCall + + +@pytest.fixture(scope="function", name="delete_all_analytics_rules_v1") +def clear_typesense_analytics_rules_v1() -> None: + """Remove all analytics_rules from the Typesense server.""" + url = "http://localhost:8108/analytics/rules" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + + # Get the list of rules + response = requests.get(url, headers=headers, timeout=3) + response.raise_for_status() + analytics_rules = response.json() + + # Delete each analytics_rule + for analytics_rule_set in analytics_rules["rules"]: + analytics_rule_id = analytics_rule_set.get("name") + delete_url = f"{url}/{analytics_rule_id}" + delete_response = requests.delete(delete_url, headers=headers, timeout=3) + delete_response.raise_for_status() + + +@pytest.fixture(scope="function", name="create_analytics_rule_v1") +def create_analytics_rule_v1_fixture( + create_collection: None, + create_query_collection: None, +) -> None: + """Create a collection in the Typesense server.""" + url = "http://localhost:8108/analytics/rules" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + analytics_rule_data = { + "name": "company_analytics_rule", + "type": "nohits_queries", + "params": { + "source": { + "collections": ["companies"], + }, + "destination": {"collection": "companies_queries"}, + }, + } + + response = requests.post(url, headers=headers, json=analytics_rule_data, timeout=3) + response.raise_for_status() + + +@pytest.fixture(scope="function", name="fake_analytics_rules_v1") +def fake_analytics_rules_v1_fixture(fake_api_call: ApiCall) -> AnalyticsRulesV1: + """Return a AnalyticsRule object with test values.""" + return AnalyticsRulesV1(fake_api_call) + + +@pytest.fixture(scope="function", name="actual_analytics_rules_v1") +def actual_analytics_rules_v1_fixture(actual_api_call: ApiCall) -> AnalyticsRulesV1: + """Return a AnalyticsRules object using a real API.""" + return AnalyticsRulesV1(actual_api_call) + + +@pytest.fixture(scope="function", name="fake_analytics_rule_v1") +def fake_analytics_rule_v1_fixture(fake_api_call: ApiCall) -> AnalyticsRuleV1: + """Return a AnalyticsRule object with test values.""" + return AnalyticsRuleV1(fake_api_call, "company_analytics_rule") + + diff --git a/tests/import_test.py b/tests/import_test.py index 616ec11..b33bb39 100644 --- a/tests/import_test.py +++ b/tests/import_test.py @@ -10,7 +10,7 @@ typing_module_names = [ "alias", - "analytics_rule", + "analytics_rule_v1", "collection", "conversations_model", "debug", @@ -25,8 +25,8 @@ module_names = [ "aliases", - "analytics_rule", - "analytics_rules", + "analytics_rule_v1", + "analytics_rules_v1", "api_call", "client", "collection", diff --git a/tests/synonym_test.py b/tests/synonym_test.py index 98caa08..d25d937 100644 --- a/tests/synonym_test.py +++ b/tests/synonym_test.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest import requests_mock from tests.utils.object_assertions import ( @@ -9,12 +10,29 @@ assert_object_lists_match, assert_to_contain_object, ) +from tests.utils.version import is_v30_or_above from typesense.api_call import ApiCall from typesense.collections import Collections +from typesense.client import Client from typesense.synonym import Synonym, SynonymDeleteSchema from typesense.synonyms import SynonymSchema +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Skip synonym tests on v30+", +) + + def test_init(fake_api_call: ApiCall) -> None: """Test that the Synonym object is initialized correctly.""" synonym = Synonym(fake_api_call, "companies", "company_synonym") diff --git a/tests/synonyms_test.py b/tests/synonyms_test.py index 2071dbc..81ae716 100644 --- a/tests/synonyms_test.py +++ b/tests/synonyms_test.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest import requests_mock from tests.utils.object_assertions import ( @@ -11,9 +12,26 @@ ) from typesense.api_call import ApiCall from typesense.collections import Collections +from tests.utils.version import is_v30_or_above +from typesense.client import Client from typesense.synonyms import Synonyms, SynonymSchema, SynonymsRetrieveSchema +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Skip synonyms tests on v30+", +) + + def test_init(fake_api_call: ApiCall) -> None: """Test that the Synonyms object is initialized correctly.""" synonyms = Synonyms(fake_api_call, "companies") diff --git a/tests/utils/version.py b/tests/utils/version.py new file mode 100644 index 0000000..ba3ca93 --- /dev/null +++ b/tests/utils/version.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typesense.client import Client + + +def is_v30_or_above(client: Client) -> bool: + try: + debug = client.debug.retrieve() + version = debug.get("version") + if version == "nightly": + return True + try: + numbered = str(version).split("v")[1] + return int(numbered) >= 30 + except Exception: + return False + except Exception: + return False + + From 47b4c42711bb9af21196c953610e147911e24cae Mon Sep 17 00:00:00 2001 From: Harisaran G Date: Tue, 26 Aug 2025 16:03:20 +0530 Subject: [PATCH 02/33] add: synonym_set APIs --- src/typesense/analytics_v1.py | 16 ++- src/typesense/client.py | 2 + src/typesense/synonym.py | 14 +++ src/typesense/synonym_set.py | 43 +++++++ src/typesense/synonym_sets.py | 50 ++++++++ src/typesense/synonyms.py | 14 +++ src/typesense/types/synonym_set.py | 72 +++++++++++ tests/analytics_test.py | 2 +- tests/fixtures/synonym_set_fixtures.py | 73 +++++++++++ tests/import_test.py | 3 + tests/synonym_set_test.py | 127 +++++++++++++++++++ tests/synonym_sets_test.py | 163 +++++++++++++++++++++++++ 12 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 src/typesense/synonym_set.py create mode 100644 src/typesense/synonym_sets.py create mode 100644 src/typesense/types/synonym_set.py create mode 100644 tests/fixtures/synonym_set_fixtures.py create mode 100644 tests/synonym_set_test.py create mode 100644 tests/synonym_sets_test.py diff --git a/src/typesense/analytics_v1.py b/src/typesense/analytics_v1.py index b75bfbb..cbacc4b 100644 --- a/src/typesense/analytics_v1.py +++ b/src/typesense/analytics_v1.py @@ -19,6 +19,9 @@ from typesense.analytics_rules_v1 import AnalyticsRulesV1 from typesense.api_call import ApiCall +from typesense.logger import logger + +_analytics_v1_deprecation_warned = False class AnalyticsV1(object): @@ -39,6 +42,17 @@ def __init__(self, api_call: ApiCall) -> None: Args: api_call (ApiCall): The API call object for making requests. """ - self.rules = AnalyticsRulesV1(api_call) + self._rules = AnalyticsRulesV1(api_call) + + @property + def rules(self) -> AnalyticsRulesV1: + global _analytics_v1_deprecation_warned + if not _analytics_v1_deprecation_warned: + logger.warning( + "AnalyticsV1 is deprecated and will be removed in a future release. " + "Use client.analytics instead." + ) + _analytics_v1_deprecation_warned = True + return self._rules diff --git a/src/typesense/client.py b/src/typesense/client.py index d5d7dee..92354b2 100644 --- a/src/typesense/client.py +++ b/src/typesense/client.py @@ -51,6 +51,7 @@ from typesense.operations import Operations from typesense.stemming import Stemming from typesense.stopwords import Stopwords +from typesense.synonym_sets import SynonymSets TDoc = typing.TypeVar("TDoc", bound=DocumentSchema) @@ -109,6 +110,7 @@ def __init__(self, config_dict: ConfigDict) -> None: self.operations = Operations(self.api_call) self.debug = Debug(self.api_call) self.stopwords = Stopwords(self.api_call) + self.synonym_sets = SynonymSets(self.api_call) self.metrics = Metrics(self.api_call) self.conversations_models = ConversationsModels(self.api_call) self.nl_search_models = NLSearchModels(self.api_call) diff --git a/src/typesense/synonym.py b/src/typesense/synonym.py index 096affc..4d5b73b 100644 --- a/src/typesense/synonym.py +++ b/src/typesense/synonym.py @@ -22,6 +22,9 @@ """ from typesense.api_call import ApiCall +from typesense.logger import logger + +_synonym_deprecation_warned = False from typesense.types.synonym import SynonymDeleteSchema, SynonymSchema @@ -63,6 +66,7 @@ def retrieve(self) -> SynonymSchema: Returns: SynonymSchema: The schema containing the synonym details. """ + self._maybe_warn_deprecation() return self.api_call.get(self._endpoint_path(), entity_type=SynonymSchema) def delete(self) -> SynonymDeleteSchema: @@ -72,6 +76,7 @@ def delete(self) -> SynonymDeleteSchema: Returns: SynonymDeleteSchema: The schema containing the deletion response. """ + self._maybe_warn_deprecation() return self.api_call.delete( self._endpoint_path(), entity_type=SynonymDeleteSchema, @@ -95,3 +100,12 @@ def _endpoint_path(self) -> str: self.synonym_id, ], ) + + def _maybe_warn_deprecation(self) -> None: + global _synonym_deprecation_warned + if not _synonym_deprecation_warned: + logger.warning( + "The synonyms API (collections/{collection}/synonyms) is deprecated and will be " + "removed in a future release. Use synonym sets (synonym_sets) instead." + ) + _synonym_deprecation_warned = True diff --git a/src/typesense/synonym_set.py b/src/typesense/synonym_set.py new file mode 100644 index 0000000..c6c6b3b --- /dev/null +++ b/src/typesense/synonym_set.py @@ -0,0 +1,43 @@ +"""Client for single Synonym Set operations.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.synonym_set import ( + SynonymSetDeleteSchema, + SynonymSetRetrieveSchema, +) + + +class SynonymSet: + def __init__(self, api_call: ApiCall, name: str) -> None: + self.api_call = api_call + self.name = name + + @property + def _endpoint_path(self) -> str: + from typesense.synonym_sets import SynonymSets + + return "/".join([SynonymSets.resource_path, self.name]) + + def retrieve(self) -> SynonymSetRetrieveSchema: + response: SynonymSetRetrieveSchema = self.api_call.get( + self._endpoint_path, + as_json=True, + entity_type=SynonymSetRetrieveSchema, + ) + return response + + def delete(self) -> SynonymSetDeleteSchema: + response: SynonymSetDeleteSchema = self.api_call.delete( + self._endpoint_path, + entity_type=SynonymSetDeleteSchema, + ) + return response + + diff --git a/src/typesense/synonym_sets.py b/src/typesense/synonym_sets.py new file mode 100644 index 0000000..a1a38e5 --- /dev/null +++ b/src/typesense/synonym_sets.py @@ -0,0 +1,50 @@ +"""Client for Synonym Sets collection operations.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.synonym_set import ( + SynonymSetCreateSchema, + SynonymSetDeleteSchema, + SynonymSetRetrieveSchema, + SynonymSetSchema, +) + + +class SynonymSets: + resource_path: typing.Final[str] = "/synonym_sets" + + def __init__(self, api_call: ApiCall) -> None: + self.api_call = api_call + + def retrieve(self) -> typing.List[SynonymSetSchema]: + response: typing.List[SynonymSetSchema] = self.api_call.get( + SynonymSets.resource_path, + as_json=True, + entity_type=typing.List[SynonymSetSchema], + ) + return response + + def __getitem__(self, synonym_set_name: str) -> "SynonymSet": + from typesense.synonym_set import SynonymSet as PerSet + + return PerSet(self.api_call, synonym_set_name) + + def upsert( + self, + synonym_set_name: str, + payload: SynonymSetCreateSchema, + ) -> SynonymSetSchema: + response: SynonymSetSchema = self.api_call.put( + "/".join([SynonymSets.resource_path, synonym_set_name]), + body=payload, + entity_type=SynonymSetSchema, + ) + return response + + diff --git a/src/typesense/synonyms.py b/src/typesense/synonyms.py index abd6211..c1bd6b7 100644 --- a/src/typesense/synonyms.py +++ b/src/typesense/synonyms.py @@ -34,6 +34,9 @@ SynonymSchema, SynonymsRetrieveSchema, ) +from typesense.logger import logger + +_synonyms_deprecation_warned = False if sys.version_info >= (3, 11): import typing @@ -98,6 +101,7 @@ def upsert(self, synonym_id: str, schema: SynonymCreateSchema) -> SynonymSchema: Returns: SynonymSchema: The created or updated synonym. """ + self._maybe_warn_deprecation() response = self.api_call.put( self._endpoint_path(synonym_id), body=schema, @@ -112,6 +116,7 @@ def retrieve(self) -> SynonymsRetrieveSchema: Returns: SynonymsRetrieveSchema: The schema containing all synonyms. """ + self._maybe_warn_deprecation() response = self.api_call.get( self._endpoint_path(), entity_type=SynonymsRetrieveSchema, @@ -139,3 +144,12 @@ def _endpoint_path(self, synonym_id: typing.Union[str, None] = None) -> str: synonym_id, ], ) + + def _maybe_warn_deprecation(self) -> None: + global _synonyms_deprecation_warned + if not _synonyms_deprecation_warned: + logger.warning( + "The synonyms API (collections/{collection}/synonyms) is deprecated and will be " + "removed in a future release. Use synonym sets (synonym_sets) instead." + ) + _synonyms_deprecation_warned = True diff --git a/src/typesense/types/synonym_set.py b/src/typesense/types/synonym_set.py new file mode 100644 index 0000000..c786d6b --- /dev/null +++ b/src/typesense/types/synonym_set.py @@ -0,0 +1,72 @@ +"""Synonym Set types for Typesense Python Client.""" + +import sys + +from typesense.types.collection import Locales + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + + +class SynonymItemSchema(typing.TypedDict): + """ + Schema representing an individual synonym item inside a synonym set. + + Attributes: + id (str): Unique identifier for the synonym item. + synonyms (list[str]): The synonyms array. + root (str, optional): For 1-way synonyms, indicates the root word that words in + the synonyms parameter map to. + locale (Locales, optional): Locale for the synonym. + symbols_to_index (list[str], optional): Symbols to index as-is in synonyms. + """ + + id: str + synonyms: typing.List[str] + root: typing.NotRequired[str] + locale: typing.NotRequired[Locales] + symbols_to_index: typing.NotRequired[typing.List[str]] + + +class SynonymSetCreateSchema(typing.TypedDict): + """ + Schema for creating or updating a synonym set. + + Attributes: + items (list[SynonymItemSchema]): Array of synonym items. + """ + + items: typing.List[SynonymItemSchema] + + +class SynonymSetSchema(SynonymSetCreateSchema): + """ + Schema representing a synonym set. + + Attributes: + name (str): Name of the synonym set. + """ + + name: str + + +class SynonymSetsRetrieveSchema(typing.List[SynonymSetSchema]): + """Deprecated alias for list of synonym sets; use List[SynonymSetSchema] directly.""" + + +class SynonymSetRetrieveSchema(SynonymSetCreateSchema): + """Response schema for retrieving a single synonym set by name.""" + + +class SynonymSetDeleteSchema(typing.TypedDict): + """Response schema for deleting a synonym set. + + Attributes: + name (str): Name of the deleted synonym set. + """ + + name: str + + diff --git a/tests/analytics_test.py b/tests/analytics_test.py index 5d9e56d..a7e2276 100644 --- a/tests/analytics_test.py +++ b/tests/analytics_test.py @@ -7,7 +7,7 @@ from typesense.api_call import ApiCall -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +@pytest.mark.skipif(not is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsV1 object is initialized correctly.""" analytics = Analytics(fake_api_call) diff --git a/tests/fixtures/synonym_set_fixtures.py b/tests/fixtures/synonym_set_fixtures.py new file mode 100644 index 0000000..c4c4341 --- /dev/null +++ b/tests/fixtures/synonym_set_fixtures.py @@ -0,0 +1,73 @@ +"""Fixtures for the synonym set tests.""" + +import pytest +import requests + +from typesense.api_call import ApiCall +from typesense.synonym_set import SynonymSet +from typesense.synonym_sets import SynonymSets + + +@pytest.fixture(scope="function", name="create_synonym_set") +def create_synonym_set_fixture() -> None: + """Create a synonym set in the Typesense server.""" + url = "http://localhost:8108/synonym_sets/test-set" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + data = { + "items": [ + { + "id": "company_synonym", + "synonyms": ["companies", "corporations", "firms"], + } + ] + } + + resp = requests.put(url, headers=headers, json=data, timeout=3) + resp.raise_for_status() + + +@pytest.fixture(scope="function", name="delete_all_synonym_sets") +def clear_typesense_synonym_sets() -> None: + """Remove all synonym sets from the Typesense server.""" + url = "http://localhost:8108/synonym_sets" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + + # Get the list of synonym sets + response = requests.get(url, headers=headers, timeout=3) + response.raise_for_status() + data = response.json() + + # Delete each synonym set + for synset in data: + name = synset.get("name") + if not name: + continue + delete_url = f"{url}/{name}" + delete_response = requests.delete(delete_url, headers=headers, timeout=3) + delete_response.raise_for_status() + + +@pytest.fixture(scope="function", name="actual_synonym_sets") +def actual_synonym_sets_fixture(actual_api_call: ApiCall) -> SynonymSets: + """Return a SynonymSets object using a real API.""" + return SynonymSets(actual_api_call) + + +@pytest.fixture(scope="function", name="actual_synonym_set") +def actual_synonym_set_fixture(actual_api_call: ApiCall) -> SynonymSet: + """Return a SynonymSet object using a real API.""" + return SynonymSet(actual_api_call, "test-set") + + +@pytest.fixture(scope="function", name="fake_synonym_sets") +def fake_synonym_sets_fixture(fake_api_call: ApiCall) -> SynonymSets: + """Return a SynonymSets object with test values.""" + return SynonymSets(fake_api_call) + + +@pytest.fixture(scope="function", name="fake_synonym_set") +def fake_synonym_set_fixture(fake_api_call: ApiCall) -> SynonymSet: + """Return a SynonymSet object with test values.""" + return SynonymSet(fake_api_call, "test-set") + + diff --git a/tests/import_test.py b/tests/import_test.py index b33bb39..9aec70e 100644 --- a/tests/import_test.py +++ b/tests/import_test.py @@ -20,6 +20,7 @@ "operations", "override", "stopword", + "synonym_set", "synonym", ] @@ -41,6 +42,8 @@ "overrides", "operations", "synonyms", + "synonym_set", + "synonym_sets", "preprocess", "stopwords", ] diff --git a/tests/synonym_set_test.py b/tests/synonym_set_test.py new file mode 100644 index 0000000..85ebb01 --- /dev/null +++ b/tests/synonym_set_test.py @@ -0,0 +1,127 @@ +"""Tests for the SynonymSet class.""" + +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.object_assertions import assert_match_object, assert_object_lists_match +from tests.utils.version import is_v30_or_above +from typesense.api_call import ApiCall +from typesense.client import Client +from typesense.synonym_set import SynonymSet +from typesense.types.synonym_set import SynonymSetDeleteSchema, SynonymSetRetrieveSchema + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Run synonym set tests only on v30+", +) + + +def test_init(fake_api_call: ApiCall) -> None: + """Test that the SynonymSet object is initialized correctly.""" + synset = SynonymSet(fake_api_call, "test-set") + + assert synset.name == "test-set" + assert_match_object(synset.api_call, fake_api_call) + assert_object_lists_match( + synset.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + assert_match_object( + synset.api_call.config.nearest_node, + fake_api_call.config.nearest_node, + ) + assert synset._endpoint_path == "/synonym_sets/test-set" # noqa: WPS437 + + +def test_retrieve(fake_synonym_set: SynonymSet) -> None: + """Test that the SynonymSet object can retrieve a synonym set.""" + json_response: SynonymSetRetrieveSchema = { + "items": [ + { + "id": "company_synonym", + "synonyms": ["companies", "corporations", "firms"], + } + ] + } + + with requests_mock.Mocker() as mock: + mock.get( + "/synonym_sets/test-set", + json=json_response, + ) + + response = fake_synonym_set.retrieve() + + assert len(mock.request_history) == 1 + assert mock.request_history[0].method == "GET" + assert ( + mock.request_history[0].url + == "http://nearest:8108/synonym_sets/test-set" + ) + assert response == json_response + + +def test_delete(fake_synonym_set: SynonymSet) -> None: + """Test that the SynonymSet object can delete a synonym set.""" + json_response: SynonymSetDeleteSchema = { + "name": "test-set", + } + with requests_mock.Mocker() as mock: + mock.delete( + "/synonym_sets/test-set", + json=json_response, + ) + + response = fake_synonym_set.delete() + + assert len(mock.request_history) == 1 + assert mock.request_history[0].method == "DELETE" + assert ( + mock.request_history[0].url + == "http://nearest:8108/synonym_sets/test-set" + ) + assert response == json_response + + +def test_actual_retrieve( + actual_synonym_sets: "SynonymSets", # type: ignore[name-defined] + delete_all_synonym_sets: None, + create_synonym_set: None, +) -> None: + """Test that the SynonymSet object can retrieve a synonym set from Typesense Server.""" + response = actual_synonym_sets["test-set"].retrieve() + + assert response == { + "name": "test-set", + "items": [ + { + "id": "company_synonym", + "root": "", + "synonyms": ["companies", "corporations", "firms"], + } + ] + } + + +def test_actual_delete( + actual_synonym_sets: "SynonymSets", # type: ignore[name-defined] + create_synonym_set: None, +) -> None: + """Test that the SynonymSet object can delete a synonym set from Typesense Server.""" + response = actual_synonym_sets["test-set"].delete() + + assert response == {"name": "test-set"} + + diff --git a/tests/synonym_sets_test.py b/tests/synonym_sets_test.py new file mode 100644 index 0000000..24cea59 --- /dev/null +++ b/tests/synonym_sets_test.py @@ -0,0 +1,163 @@ +"""Tests for the SynonymSets class.""" + +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.object_assertions import ( + assert_match_object, + assert_object_lists_match, + assert_to_contain_object, +) +from tests.utils.version import is_v30_or_above +from typesense.api_call import ApiCall +from typesense.client import Client +from typesense.synonym_sets import SynonymSets +from typesense.types.synonym_set import ( + SynonymSetCreateSchema, + SynonymSetSchema, +) + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Run synonym sets tests only on v30+", +) + + +def test_init(fake_api_call: ApiCall) -> None: + """Test that the SynonymSets object is initialized correctly.""" + synsets = SynonymSets(fake_api_call) + + assert_match_object(synsets.api_call, fake_api_call) + assert_object_lists_match( + synsets.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + assert_match_object( + synsets.api_call.config.nearest_node, + fake_api_call.config.nearest_node, + ) + + +def test_retrieve(fake_synonym_sets: SynonymSets) -> None: + """Test that the SynonymSets object can retrieve synonym sets.""" + json_response = [ + { + "name": "test-set", + "items": [ + { + "id": "company_synonym", + "root": "", + "synonyms": ["companies", "corporations", "firms"], + } + ], + } + ] + + with requests_mock.Mocker() as mock: + mock.get( + "http://nearest:8108/synonym_sets", + json=json_response, + ) + + response = fake_synonym_sets.retrieve() + + assert isinstance(response, list) + assert len(response) == 1 + assert response == json_response + + +def test_create(fake_synonym_sets: SynonymSets) -> None: + """Test that the SynonymSets object can create a synonym set.""" + json_response: SynonymSetSchema = { + "name": "test-set", + "items": [ + { + "id": "company_synonym", + "synonyms": ["companies", "corporations", "firms"], + } + ], + } + + with requests_mock.Mocker() as mock: + mock.put( + "http://nearest:8108/synonym_sets/test-set", + json=json_response, + ) + + payload: SynonymSetCreateSchema = { + "items": [ + { + "id": "company_synonym", + "synonyms": ["companies", "corporations", "firms"], + } + ] + } + fake_synonym_sets.upsert("test-set", payload) + + assert mock.call_count == 1 + assert mock.called is True + assert mock.last_request.method == "PUT" + assert ( + mock.last_request.url == "http://nearest:8108/synonym_sets/test-set" + ) + assert mock.last_request.json() == payload + + +def test_actual_create( + actual_synonym_sets: SynonymSets, + delete_all_synonym_sets: None, +) -> None: + """Test that the SynonymSets object can create a synonym set on Typesense Server.""" + response = actual_synonym_sets.upsert( + "test-set", + { + "items": [ + { + "id": "company_synonym", + "synonyms": ["companies", "corporations", "firms"], + } + ] + }, + ) + + assert response == { + "name": "test-set", + "items": [ + { + "id": "company_synonym", + "root": "", + "synonyms": ["companies", "corporations", "firms"], + } + ], + } + + +def test_actual_retrieve( + actual_synonym_sets: SynonymSets, + delete_all_synonym_sets: None, + create_synonym_set: None, +) -> None: + """Test that the SynonymSets object can retrieve a synonym set from Typesense Server.""" + response = actual_synonym_sets.retrieve() + + assert isinstance(response, list) + assert_to_contain_object( + response[0], + { + "name": "test-set", + }, + ) + + From afd5d92e0af1f55d6f431058ac451b561061f34b Mon Sep 17 00:00:00 2001 From: Harisaran G Date: Tue, 23 Sep 2025 11:02:21 +0530 Subject: [PATCH 03/33] add: curation_sets --- src/typesense/curation_set.py | 97 +++++++++++++++++++ src/typesense/curation_sets.py | 53 ++++++++++ src/typesense/synonym_set.py | 51 ++++++++++ src/typesense/types/curation_set.py | 99 +++++++++++++++++++ src/typesense/types/synonym_set.py | 10 +- tests/analytics_rule_v1_test.py | 18 +++- tests/analytics_rules_test.py | 4 +- tests/analytics_rules_v1_test.py | 21 ++-- tests/collection_test.py | 9 +- tests/collections_test.py | 6 +- tests/curation_set_test.py | 123 ++++++++++++++++++++++++ tests/curation_sets_test.py | 112 +++++++++++++++++++++ tests/fixtures/analytics_fixtures.py | 2 +- tests/fixtures/curation_set_fixtures.py | 73 ++++++++++++++ tests/override_test.py | 14 +++ tests/overrides_test.py | 14 ++- tests/synonym_set_items_test.py | 85 ++++++++++++++++ 17 files changed, 768 insertions(+), 23 deletions(-) create mode 100644 src/typesense/curation_set.py create mode 100644 src/typesense/curation_sets.py create mode 100644 src/typesense/types/curation_set.py create mode 100644 tests/curation_set_test.py create mode 100644 tests/curation_sets_test.py create mode 100644 tests/fixtures/curation_set_fixtures.py create mode 100644 tests/synonym_set_items_test.py diff --git a/src/typesense/curation_set.py b/src/typesense/curation_set.py new file mode 100644 index 0000000..f0db7e4 --- /dev/null +++ b/src/typesense/curation_set.py @@ -0,0 +1,97 @@ +"""Client for single Curation Set operations, including items APIs.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.curation_set import ( + CurationSetSchema, + CurationSetDeleteSchema, + CurationSetUpsertSchema, + CurationSetListItemResponseSchema, + CurationItemSchema, + CurationItemDeleteSchema, +) + + +class CurationSet: + def __init__(self, api_call: ApiCall, name: str) -> None: + self.api_call = api_call + self.name = name + + @property + def _endpoint_path(self) -> str: + from typesense.curation_sets import CurationSets + + return "/".join([CurationSets.resource_path, self.name]) + + def retrieve(self) -> CurationSetSchema: + response: CurationSetSchema = self.api_call.get( + self._endpoint_path, + as_json=True, + entity_type=CurationSetSchema, + ) + return response + + def delete(self) -> CurationSetDeleteSchema: + response: CurationSetDeleteSchema = self.api_call.delete( + self._endpoint_path, + entity_type=CurationSetDeleteSchema, + ) + return response + + # Items sub-resource + @property + def _items_path(self) -> str: + return "/".join([self._endpoint_path, "items"]) # /curation_sets/{name}/items + + def list_items( + self, + *, + limit: typing.Union[int, None] = None, + offset: typing.Union[int, None] = None, + ) -> CurationSetListItemResponseSchema: + params: typing.Dict[str, typing.Union[int, None]] = { + "limit": limit, + "offset": offset, + } + # Filter out None values to avoid sending them + clean_params: typing.Dict[str, int] = { + k: v for k, v in params.items() if v is not None # type: ignore[dict-item] + } + response: CurationSetListItemResponseSchema = self.api_call.get( + self._items_path, + as_json=True, + entity_type=CurationSetListItemResponseSchema, + params=clean_params or None, + ) + return response + + def get_item(self, item_id: str) -> CurationItemSchema: + response: CurationItemSchema = self.api_call.get( + "/".join([self._items_path, item_id]), + as_json=True, + entity_type=CurationItemSchema, + ) + return response + + def upsert_item(self, item_id: str, item: CurationItemSchema) -> CurationItemSchema: + response: CurationItemSchema = self.api_call.put( + "/".join([self._items_path, item_id]), + body=item, + entity_type=CurationItemSchema, + ) + return response + + def delete_item(self, item_id: str) -> CurationItemDeleteSchema: + response: CurationItemDeleteSchema = self.api_call.delete( + "/".join([self._items_path, item_id]), + entity_type=CurationItemDeleteSchema, + ) + return response + + diff --git a/src/typesense/curation_sets.py b/src/typesense/curation_sets.py new file mode 100644 index 0000000..d257f42 --- /dev/null +++ b/src/typesense/curation_sets.py @@ -0,0 +1,53 @@ +"""Client for Curation Sets collection operations.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + +from typesense.api_call import ApiCall +from typesense.types.curation_set import ( + CurationSetSchema, + CurationSetUpsertSchema, + CurationSetsListResponseSchema, + CurationSetListItemResponseSchema, + CurationItemDeleteSchema, + CurationSetDeleteSchema, + CurationItemSchema, +) + + +class CurationSets: + resource_path: typing.Final[str] = "/curation_sets" + + def __init__(self, api_call: ApiCall) -> None: + self.api_call = api_call + + def retrieve(self) -> CurationSetsListResponseSchema: + response: CurationSetsListResponseSchema = self.api_call.get( + CurationSets.resource_path, + as_json=True, + entity_type=CurationSetsListResponseSchema, + ) + return response + + def __getitem__(self, curation_set_name: str) -> "CurationSet": + from typesense.curation_set import CurationSet as PerSet + + return PerSet(self.api_call, curation_set_name) + + def upsert( + self, + curation_set_name: str, + payload: CurationSetUpsertSchema, + ) -> CurationSetSchema: + response: CurationSetSchema = self.api_call.put( + "/".join([CurationSets.resource_path, curation_set_name]), + body=payload, + entity_type=CurationSetSchema, + ) + return response + + diff --git a/src/typesense/synonym_set.py b/src/typesense/synonym_set.py index c6c6b3b..daa9c7d 100644 --- a/src/typesense/synonym_set.py +++ b/src/typesense/synonym_set.py @@ -11,6 +11,8 @@ from typesense.types.synonym_set import ( SynonymSetDeleteSchema, SynonymSetRetrieveSchema, + SynonymItemSchema, + SynonymItemDeleteSchema, ) @@ -39,5 +41,54 @@ def delete(self) -> SynonymSetDeleteSchema: entity_type=SynonymSetDeleteSchema, ) return response + + @property + def _items_path(self) -> str: + return "/".join([self._endpoint_path, "items"]) # /synonym_sets/{name}/items + + def list_items( + self, + *, + limit: typing.Union[int, None] = None, + offset: typing.Union[int, None] = None, + ) -> typing.List[SynonymItemSchema]: + params: typing.Dict[str, typing.Union[int, None]] = { + "limit": limit, + "offset": offset, + } + clean_params: typing.Dict[str, int] = { + k: v for k, v in params.items() if v is not None # type: ignore[dict-item] + } + response: typing.List[SynonymItemSchema] = self.api_call.get( + self._items_path, + as_json=True, + entity_type=typing.List[SynonymItemSchema], + params=clean_params or None, + ) + return response + + def get_item(self, item_id: str) -> SynonymItemSchema: + response: SynonymItemSchema = self.api_call.get( + "/".join([self._items_path, item_id]), + as_json=True, + entity_type=SynonymItemSchema, + ) + return response + + def upsert_item(self, item_id: str, item: SynonymItemSchema) -> SynonymItemSchema: + response: SynonymItemSchema = self.api_call.put( + "/".join([self._items_path, item_id]), + body=item, + entity_type=SynonymItemSchema, + ) + return response + + def delete_item(self, item_id: str) -> typing.Dict[str, str]: + # API returns {"id": "..."} for delete; openapi defines SynonymItemDeleteResponse with name but for items it's id + response: SynonymItemDeleteSchema = self.api_call.delete( + "/".join([self._items_path, item_id]), + entity_type=typing.Dict[str, str], + ) + return response diff --git a/src/typesense/types/curation_set.py b/src/typesense/types/curation_set.py new file mode 100644 index 0000000..3a8c617 --- /dev/null +++ b/src/typesense/types/curation_set.py @@ -0,0 +1,99 @@ +"""Curation Set types for Typesense Python Client.""" + +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing + + +class CurationIncludeSchema(typing.TypedDict): + """ + Schema representing an included document for a curation rule. + """ + + id: str + position: int + + +class CurationExcludeSchema(typing.TypedDict): + """ + Schema representing an excluded document for a curation rule. + """ + + id: str + + +class CurationRuleSchema(typing.TypedDict, total=False): + """ + Schema representing rule conditions for a curation item. + """ + + query: str + match: typing.Literal["exact", "contains"] + filter_by: str + tags: typing.List[str] + + +class CurationItemSchema(typing.TypedDict, total=False): + """ + Schema for a single curation item (aka CurationObject in the API). + """ + + id: str + rule: CurationRuleSchema + includes: typing.List[CurationIncludeSchema] + excludes: typing.List[CurationExcludeSchema] + filter_by: str + sort_by: str + replace_query: str + remove_matched_tokens: bool + filter_curated_hits: bool + stop_processing: bool + metadata: typing.Dict[str, typing.Any] + + +class CurationSetUpsertSchema(typing.TypedDict): + """ + Payload schema to create or replace a curation set. + """ + + items: typing.List[CurationItemSchema] + + +class CurationSetSchema(CurationSetUpsertSchema): + """ + Response schema for a curation set. + """ + + name: str + + +class CurationSetsListEntrySchema(typing.TypedDict): + """A single entry in the curation sets list response.""" + + name: str + items: typing.List[CurationItemSchema] + + +class CurationSetsListResponseSchema(typing.List[CurationSetsListEntrySchema]): + """List response for all curation sets.""" + + +class CurationSetListItemResponseSchema(typing.List[CurationItemSchema]): + """List response for items under a specific curation set.""" + + +class CurationItemDeleteSchema(typing.TypedDict): + """Response schema for deleting a curation item.""" + + id: str + + +class CurationSetDeleteSchema(typing.TypedDict): + """Response schema for deleting a curation set.""" + + name: str + + diff --git a/src/typesense/types/synonym_set.py b/src/typesense/types/synonym_set.py index c786d6b..9d0dfe1 100644 --- a/src/typesense/types/synonym_set.py +++ b/src/typesense/types/synonym_set.py @@ -29,6 +29,12 @@ class SynonymItemSchema(typing.TypedDict): locale: typing.NotRequired[Locales] symbols_to_index: typing.NotRequired[typing.List[str]] +class SynonymItemDeleteSchema(typing.TypedDict): + """ + Schema for deleting a synonym item. + """ + + id: str class SynonymSetCreateSchema(typing.TypedDict): """ @@ -67,6 +73,4 @@ class SynonymSetDeleteSchema(typing.TypedDict): name (str): Name of the deleted synonym set. """ - name: str - - + name: str \ No newline at end of file diff --git a/tests/analytics_rule_v1_test.py b/tests/analytics_rule_v1_test.py index 8cc970b..4e3534c 100644 --- a/tests/analytics_rule_v1_test.py +++ b/tests/analytics_rule_v1_test.py @@ -12,8 +12,16 @@ from typesense.api_call import ApiCall from typesense.types.analytics_rule_v1 import RuleDeleteSchema, RuleSchemaForQueries +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Skip AnalyticsV1 tests on v30+" +) -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsRuleV1 object is initialized correctly.""" analytics_rule = AnalyticsRuleV1(fake_api_call, "company_analytics_rule") @@ -34,7 +42,7 @@ def test_init(fake_api_call: ApiCall) -> None: ) -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_retrieve(fake_analytics_rule: AnalyticsRuleV1) -> None: """Test that the AnalyticsRuleV1 object can retrieve an analytics_rule.""" json_response: RuleSchemaForQueries = { @@ -65,7 +73,7 @@ def test_retrieve(fake_analytics_rule: AnalyticsRuleV1) -> None: assert response == json_response -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_delete(fake_analytics_rule: AnalyticsRuleV1) -> None: """Test that the AnalyticsRuleV1 object can delete an analytics_rule.""" json_response: RuleDeleteSchema = { @@ -88,7 +96,7 @@ def test_delete(fake_analytics_rule: AnalyticsRuleV1) -> None: assert response == json_response -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_actual_retrieve( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -111,7 +119,7 @@ def test_actual_retrieve( assert response == expected -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_actual_delete( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, diff --git a/tests/analytics_rules_test.py b/tests/analytics_rules_test.py index ef67bb6..81fce0b 100644 --- a/tests/analytics_rules_test.py +++ b/tests/analytics_rules_test.py @@ -40,7 +40,7 @@ def test_rules_create(fake_api_call) -> None: "name": "company_analytics_rule", "type": "popular_queries", "collection": "companies", - "event_type": "query", + "event_type": "search", "params": {"destination_collection": "companies_queries", "limit": 1000}, } with requests_mock.Mocker() as mock: @@ -95,7 +95,7 @@ def test_actual_create( "name": "company_analytics_rule", "type": "nohits_queries", "collection": "companies", - "event_type": "query", + "event_type": "search", "params": {"destination_collection": "companies_queries", "limit": 1000}, } resp = actual_analytics_rules.create(rule=body) diff --git a/tests/analytics_rules_v1_test.py b/tests/analytics_rules_v1_test.py index 674ac34..6ea2d91 100644 --- a/tests/analytics_rules_v1_test.py +++ b/tests/analytics_rules_v1_test.py @@ -15,7 +15,16 @@ ) -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Skip AnalyticsV1 tests on v30+" +) + def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsRulesV1 object is initialized correctly.""" analytics_rules = AnalyticsRulesV1(fake_api_call) @@ -33,7 +42,6 @@ def test_init(fake_api_call: ApiCall) -> None: assert not analytics_rules.rules -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_get_missing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> None: """Test that the AnalyticsRulesV1 object can get a missing analytics_rule.""" analytics_rule = fake_analytics_rules["company_analytics_rule"] @@ -54,7 +62,6 @@ def test_get_missing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> N ) -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_get_existing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> None: """Test that the AnalyticsRulesV1 object can get an existing analytics_rule.""" analytics_rule = fake_analytics_rules["company_analytics_rule"] @@ -65,7 +72,6 @@ def test_get_existing_analytics_rule(fake_analytics_rules: AnalyticsRulesV1) -> assert analytics_rule is fetched_analytics_rule -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_retrieve(fake_analytics_rules: AnalyticsRulesV1) -> None: """Test that the AnalyticsRulesV1 object can retrieve analytics_rules.""" json_response: RulesRetrieveSchema = { @@ -96,7 +102,6 @@ def test_retrieve(fake_analytics_rules: AnalyticsRulesV1) -> None: assert response == json_response -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") def test_create(fake_analytics_rules: AnalyticsRulesV1) -> None: """Test that the AnalyticsRulesV1 object can create a analytics_rule.""" json_response: RuleCreateSchemaForQueries = { @@ -145,7 +150,7 @@ def test_create(fake_analytics_rules: AnalyticsRulesV1) -> None: } -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_actual_create( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -177,7 +182,7 @@ def test_actual_create( } -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_actual_update( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -208,7 +213,7 @@ def test_actual_update( } -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") + def test_actual_retrieve( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, diff --git a/tests/collection_test.py b/tests/collection_test.py index 49e6422..d01ae2f 100644 --- a/tests/collection_test.py +++ b/tests/collection_test.py @@ -57,6 +57,8 @@ def test_retrieve(fake_collection: Collection) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], } with requests_mock.mock() as mock: @@ -100,6 +102,8 @@ def test_update(fake_collection: Collection) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], } with requests_mock.mock() as mock: @@ -158,6 +162,8 @@ def test_delete(fake_collection: Collection) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], } with requests_mock.mock() as mock: @@ -218,7 +224,8 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], + "curation_sets": [], } response.pop("created_at") diff --git a/tests/collections_test.py b/tests/collections_test.py index a68b468..a52c44d 100644 --- a/tests/collections_test.py +++ b/tests/collections_test.py @@ -223,7 +223,8 @@ def test_actual_create(actual_collections: Collections, delete_all: None) -> Non "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], + "curation_sets": [], } response = actual_collections.create( @@ -292,7 +293,8 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], + "curation_sets": [], }, ] diff --git a/tests/curation_set_test.py b/tests/curation_set_test.py new file mode 100644 index 0000000..d975b4c --- /dev/null +++ b/tests/curation_set_test.py @@ -0,0 +1,123 @@ +"""Tests for the CurationSet class including items APIs.""" + +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.version import is_v30_or_above +from typesense.client import Client +from typesense.curation_set import CurationSet +from typesense.types.curation_set import ( + CurationItemDeleteSchema, + CurationItemSchema, + CurationSetDeleteSchema, + CurationSetListItemResponseSchema, + CurationSetSchema, +) + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Run curation set tests only on v30+", +) + + +def test_paths(fake_curation_set: CurationSet) -> None: + assert fake_curation_set._endpoint_path == "/curation_sets/products" # noqa: WPS437 + assert fake_curation_set._items_path == "/curation_sets/products/items" # noqa: WPS437 + + +def test_retrieve(fake_curation_set: CurationSet) -> None: + json_response: CurationSetSchema = { + "name": "products", + "items": [], + } + with requests_mock.Mocker() as mock: + mock.get( + "/curation_sets/products", + json=json_response, + ) + res = fake_curation_set.retrieve() + assert res == json_response + + +def test_delete(fake_curation_set: CurationSet) -> None: + json_response: CurationSetDeleteSchema = {"name": "products"} + with requests_mock.Mocker() as mock: + mock.delete( + "/curation_sets/products", + json=json_response, + ) + res = fake_curation_set.delete() + assert res == json_response + + +def test_list_items(fake_curation_set: CurationSet) -> None: + json_response: CurationSetListItemResponseSchema = [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + ] + with requests_mock.Mocker() as mock: + mock.get( + "/curation_sets/products/items?limit=10&offset=0", + json=json_response, + ) + res = fake_curation_set.list_items(limit=10, offset=0) + assert res == json_response + + +def test_get_item(fake_curation_set: CurationSet) -> None: + json_response: CurationItemSchema = { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + with requests_mock.Mocker() as mock: + mock.get( + "/curation_sets/products/items/rule-1", + json=json_response, + ) + res = fake_curation_set.get_item("rule-1") + assert res == json_response + + +def test_upsert_item(fake_curation_set: CurationSet) -> None: + payload: CurationItemSchema = { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + json_response = payload + with requests_mock.Mocker() as mock: + mock.put( + "/curation_sets/products/items/rule-1", + json=json_response, + ) + res = fake_curation_set.upsert_item("rule-1", payload) + assert res == json_response + + +def test_delete_item(fake_curation_set: CurationSet) -> None: + json_response: CurationItemDeleteSchema = {"id": "rule-1"} + with requests_mock.Mocker() as mock: + mock.delete( + "/curation_sets/products/items/rule-1", + json=json_response, + ) + res = fake_curation_set.delete_item("rule-1") + assert res == json_response + + diff --git a/tests/curation_sets_test.py b/tests/curation_sets_test.py new file mode 100644 index 0000000..5f4a270 --- /dev/null +++ b/tests/curation_sets_test.py @@ -0,0 +1,112 @@ +"""Tests for the CurationSets class.""" + +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.object_assertions import ( + assert_match_object, + assert_object_lists_match, +) +from tests.utils.version import is_v30_or_above +from typesense.api_call import ApiCall +from typesense.client import Client +from typesense.curation_sets import CurationSets +from typesense.types.curation_set import CurationSetSchema, CurationSetUpsertSchema + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Run curation sets tests only on v30+", +) + + +def test_init(fake_api_call: ApiCall) -> None: + """Test that the CurationSets object is initialized correctly.""" + cur_sets = CurationSets(fake_api_call) + + assert_match_object(cur_sets.api_call, fake_api_call) + assert_object_lists_match( + cur_sets.api_call.node_manager.nodes, + fake_api_call.node_manager.nodes, + ) + + +def test_retrieve(fake_curation_sets: CurationSets) -> None: + """Test that the CurationSets object can retrieve curation sets.""" + json_response = [ + { + "name": "products", + "items": [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + ], + } + ] + + with requests_mock.Mocker() as mock: + mock.get( + "http://nearest:8108/curation_sets", + json=json_response, + ) + + response = fake_curation_sets.retrieve() + + assert isinstance(response, list) + assert len(response) == 1 + assert response == json_response + + +def test_upsert(fake_curation_sets: CurationSets) -> None: + """Test that the CurationSets object can upsert a curation set.""" + json_response: CurationSetSchema = { + "name": "products", + "items": [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + ], + } + + with requests_mock.Mocker() as mock: + mock.put( + "http://nearest:8108/curation_sets/products", + json=json_response, + ) + + payload: CurationSetUpsertSchema = { + "items": [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + } + ] + } + response = fake_curation_sets.upsert("products", payload) + + assert response == json_response + assert mock.call_count == 1 + assert mock.called is True + assert mock.last_request.method == "PUT" + assert ( + mock.last_request.url == "http://nearest:8108/curation_sets/products" + ) + assert mock.last_request.json() == payload + + diff --git a/tests/fixtures/analytics_fixtures.py b/tests/fixtures/analytics_fixtures.py index d0f7715..a95c8b5 100644 --- a/tests/fixtures/analytics_fixtures.py +++ b/tests/fixtures/analytics_fixtures.py @@ -38,7 +38,7 @@ def create_analytics_rule_fixture( "name": "company_analytics_rule", "type": "nohits_queries", "collection": "companies", - "event_type": "query", + "event_type": "search", "params": { "destination_collection": "companies_queries", "limit": 1000, diff --git a/tests/fixtures/curation_set_fixtures.py b/tests/fixtures/curation_set_fixtures.py new file mode 100644 index 0000000..6ab184c --- /dev/null +++ b/tests/fixtures/curation_set_fixtures.py @@ -0,0 +1,73 @@ +"""Fixtures for the curation set tests.""" + +import pytest +import requests + +from typesense.api_call import ApiCall +from typesense.curation_set import CurationSet +from typesense.curation_sets import CurationSets + + +@pytest.fixture(scope="function", name="create_curation_set") +def create_curation_set_fixture() -> None: + """Create a curation set in the Typesense server.""" + url = "http://localhost:8108/curation_sets/products" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + data = { + "items": [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + "excludes": [{"id": "999"}], + } + ] + } + + resp = requests.put(url, headers=headers, json=data, timeout=3) + resp.raise_for_status() + + +@pytest.fixture(scope="function", name="delete_all_curation_sets") +def clear_typesense_curation_sets() -> None: + """Remove all curation sets from the Typesense server.""" + url = "http://localhost:8108/curation_sets" + headers = {"X-TYPESENSE-API-KEY": "xyz"} + + response = requests.get(url, headers=headers, timeout=3) + response.raise_for_status() + data = response.json() + + for cur in data: + name = cur.get("name") + if not name: + continue + delete_url = f"{url}/{name}" + delete_response = requests.delete(delete_url, headers=headers, timeout=3) + delete_response.raise_for_status() + + +@pytest.fixture(scope="function", name="actual_curation_sets") +def actual_curation_sets_fixture(actual_api_call: ApiCall) -> CurationSets: + """Return a CurationSets object using a real API.""" + return CurationSets(actual_api_call) + + +@pytest.fixture(scope="function", name="actual_curation_set") +def actual_curation_set_fixture(actual_api_call: ApiCall) -> CurationSet: + """Return a CurationSet object using a real API.""" + return CurationSet(actual_api_call, "products") + + +@pytest.fixture(scope="function", name="fake_curation_sets") +def fake_curation_sets_fixture(fake_api_call: ApiCall) -> CurationSets: + """Return a CurationSets object with test values.""" + return CurationSets(fake_api_call) + + +@pytest.fixture(scope="function", name="fake_curation_set") +def fake_curation_set_fixture(fake_api_call: ApiCall) -> CurationSet: + """Return a CurationSet object with test values.""" + return CurationSet(fake_api_call, "products") + + diff --git a/tests/override_test.py b/tests/override_test.py index 25b05fd..0886bc5 100644 --- a/tests/override_test.py +++ b/tests/override_test.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest import requests_mock from tests.utils.object_assertions import ( @@ -13,6 +14,19 @@ from typesense.collections import Collections from typesense.override import Override, OverrideDeleteSchema from typesense.types.override import OverrideSchema +from tests.utils.version import is_v30_or_above +from typesense.client import Client + + +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Run override tests only on less than v30", +) def test_init(fake_api_call: ApiCall) -> None: diff --git a/tests/overrides_test.py b/tests/overrides_test.py index 872fe54..4593961 100644 --- a/tests/overrides_test.py +++ b/tests/overrides_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import requests_mock +import pytest from tests.utils.object_assertions import ( assert_match_object, @@ -12,7 +13,18 @@ from typesense.api_call import ApiCall from typesense.collections import Collections from typesense.overrides import OverrideRetrieveSchema, Overrides, OverrideSchema - +from tests.utils.version import is_v30_or_above +from typesense.client import Client + +pytestmark = pytest.mark.skipif( + is_v30_or_above( + Client({ + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + }) + ), + reason="Run override tests only on less than v30", +) def test_init(fake_api_call: ApiCall) -> None: """Test that the Overrides object is initialized correctly.""" diff --git a/tests/synonym_set_items_test.py b/tests/synonym_set_items_test.py new file mode 100644 index 0000000..0fb55d7 --- /dev/null +++ b/tests/synonym_set_items_test.py @@ -0,0 +1,85 @@ +"""Tests for SynonymSet item-level APIs.""" + +from __future__ import annotations + +import pytest +import requests_mock + +from tests.utils.version import is_v30_or_above +from typesense.client import Client +from typesense.synonym_set import SynonymSet +from typesense.types.synonym_set import ( + SynonymItemDeleteSchema, + SynonymItemSchema, +) + + +pytestmark = pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [ + {"host": "localhost", "port": 8108, "protocol": "http"} + ], + } + ) + ), + reason="Run synonym set items tests only on v30+", +) + + +def test_list_items(fake_synonym_set: SynonymSet) -> None: + json_response = [ + {"id": "nike", "synonyms": ["nike", "nikes"]}, + {"id": "adidas", "synonyms": ["adidas", "adi"]}, + ] + with requests_mock.Mocker() as mock: + mock.get( + "/synonym_sets/test-set/items?limit=10&offset=0", + json=json_response, + ) + res = fake_synonym_set.list_items(limit=10, offset=0) + assert res == json_response + + +def test_get_item(fake_synonym_set: SynonymSet) -> None: + json_response: SynonymItemSchema = { + "id": "nike", + "synonyms": ["nike", "nikes"], + } + with requests_mock.Mocker() as mock: + mock.get( + "/synonym_sets/test-set/items/nike", + json=json_response, + ) + res = fake_synonym_set.get_item("nike") + assert res == json_response + + +def test_upsert_item(fake_synonym_set: SynonymSet) -> None: + payload: SynonymItemSchema = { + "id": "nike", + "synonyms": ["nike", "nikes"], + } + json_response = payload + with requests_mock.Mocker() as mock: + mock.put( + "/synonym_sets/test-set/items/nike", + json=json_response, + ) + res = fake_synonym_set.upsert_item("nike", payload) + assert res == json_response + + +def test_delete_item(fake_synonym_set: SynonymSet) -> None: + json_response: SynonymItemDeleteSchema = {"id": "nike"} + with requests_mock.Mocker() as mock: + mock.delete( + "/synonym_sets/test-set/items/nike", + json=json_response, + ) + res = fake_synonym_set.delete_item("nike") + assert res == json_response + + From 6c0a52e8ede67a15eb4f848b6bea82372d722654 Mon Sep 17 00:00:00 2001 From: Harisaran G Date: Tue, 23 Sep 2025 11:12:51 +0530 Subject: [PATCH 04/33] fix: types --- src/typesense/types/analytics.py | 26 ++++++++++++-------------- src/typesense/types/curation_set.py | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/typesense/types/analytics.py b/src/typesense/types/analytics.py index 540c8b4..5f5d133 100644 --- a/src/typesense/types/analytics.py +++ b/src/typesense/types/analytics.py @@ -12,7 +12,6 @@ class AnalyticsEvent(typing.TypedDict): """Schema for an analytics event to be created.""" name: str - event_type: str data: typing.Dict[str, typing.Any] @@ -24,13 +23,12 @@ class AnalyticsEventCreateResponse(typing.TypedDict): class _AnalyticsEventItem(typing.TypedDict, total=False): name: str - event_type: str collection: str - timestamp: int + timestamp: typing.NotRequired[int] user_id: str - doc_id: str - doc_ids: typing.List[str] - query: str + doc_id: typing.NotRequired[str] + doc_ids: typing.NotRequired[typing.List[str]] + query: typing.NotRequired[str] class AnalyticsEventsResponse(typing.TypedDict): @@ -54,13 +52,13 @@ class AnalyticsStatus(typing.TypedDict, total=False): # Rules class AnalyticsRuleParams(typing.TypedDict, total=False): - destination_collection: str - limit: int - capture_search_requests: bool - meta_fields: typing.List[str] - expand_query: bool - counter_field: str - weight: int + destination_collection: typing.NotRequired[str] + limit: typing.NotRequired[int] + capture_search_requests: typing.NotRequired[bool] + meta_fields: typing.NotRequired[typing.List[str]] + expand_query: typing.NotRequired[bool] + counter_field: typing.NotRequired[str] + weight: typing.NotRequired[int] class AnalyticsRuleCreate(typing.TypedDict): @@ -68,7 +66,7 @@ class AnalyticsRuleCreate(typing.TypedDict): type: str collection: str event_type: str - params: AnalyticsRuleParams + params: typing.NotRequired[AnalyticsRuleParams] rule_tag: typing.NotRequired[str] diff --git a/src/typesense/types/curation_set.py b/src/typesense/types/curation_set.py index 3a8c617..f3d3729 100644 --- a/src/typesense/types/curation_set.py +++ b/src/typesense/types/curation_set.py @@ -30,10 +30,10 @@ class CurationRuleSchema(typing.TypedDict, total=False): Schema representing rule conditions for a curation item. """ - query: str - match: typing.Literal["exact", "contains"] - filter_by: str - tags: typing.List[str] + query: typing.NotRequired[str] + match: typing.NotRequired[typing.Literal["exact", "contains"]] + filter_by: typing.NotRequired[str] + tags: typing.NotRequired[typing.List[str]] class CurationItemSchema(typing.TypedDict, total=False): @@ -43,14 +43,14 @@ class CurationItemSchema(typing.TypedDict, total=False): id: str rule: CurationRuleSchema - includes: typing.List[CurationIncludeSchema] - excludes: typing.List[CurationExcludeSchema] - filter_by: str - sort_by: str - replace_query: str - remove_matched_tokens: bool - filter_curated_hits: bool - stop_processing: bool + includes: typing.NotRequired[typing.List[CurationIncludeSchema]] + excludes: typing.NotRequired[typing.List[CurationExcludeSchema]] + filter_by: typing.NotRequired[str] + sort_by: typing.NotRequired[str] + replace_query: typing.NotRequired[str] + remove_matched_tokens: typing.NotRequired[bool] + filter_curated_hits: typing.NotRequired[bool] + stop_processing: typing.NotRequired[bool] metadata: typing.Dict[str, typing.Any] From ca6d662a968b794d957d528af69652cf2adc39dd Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 24 Sep 2025 09:22:38 +0300 Subject: [PATCH 05/33] fix(types): add `stem_dictionary` to collection types --- src/typesense/types/collection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/typesense/types/collection.py b/src/typesense/types/collection.py index 2cb0d28..1ce839c 100644 --- a/src/typesense/types/collection.py +++ b/src/typesense/types/collection.py @@ -77,6 +77,7 @@ class CollectionFieldSchema(typing.Generic[_TType], typing.TypedDict, total=Fals optional: typing.NotRequired[bool] infix: typing.NotRequired[bool] stem: typing.NotRequired[bool] + stem_dictionary: typing.NotRequired[str] locale: typing.NotRequired[Locales] sort: typing.NotRequired[bool] store: typing.NotRequired[bool] From 50206a7775a699d6831dfed0358a5535c9914b44 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 12:31:47 +0200 Subject: [PATCH 06/33] chore: lint --- tests/api_call_test.py | 1 - tests/nl_search_models_test.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/api_call_test.py b/tests/api_call_test.py index 1d5fa11..e13c056 100644 --- a/tests/api_call_test.py +++ b/tests/api_call_test.py @@ -6,7 +6,6 @@ import sys import time -from isort import Config from pytest_mock import MockFixture if sys.version_info >= (3, 11): diff --git a/tests/nl_search_models_test.py b/tests/nl_search_models_test.py index 1558b39..daaa842 100644 --- a/tests/nl_search_models_test.py +++ b/tests/nl_search_models_test.py @@ -8,9 +8,9 @@ import pytest if sys.version_info >= (3, 11): - import typing + pass else: - import typing_extensions as typing + pass from tests.utils.object_assertions import ( assert_match_object, @@ -20,7 +20,6 @@ ) from typesense.api_call import ApiCall from typesense.nl_search_models import NLSearchModels -from typesense.types.nl_search_model import NLSearchModelSchema def test_init(fake_api_call: ApiCall) -> None: From 957e18ab1cc25fce37e9fe61f6c3fbfae46db153 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 12:34:35 +0200 Subject: [PATCH 07/33] fix: import class for `SynonymSets` on test --- tests/synonym_set_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/synonym_set_test.py b/tests/synonym_set_test.py index 85ebb01..ee6650d 100644 --- a/tests/synonym_set_test.py +++ b/tests/synonym_set_test.py @@ -10,6 +10,7 @@ from typesense.api_call import ApiCall from typesense.client import Client from typesense.synonym_set import SynonymSet +from typesense.synonym_sets import SynonymSets from typesense.types.synonym_set import SynonymSetDeleteSchema, SynonymSetRetrieveSchema @@ -96,7 +97,7 @@ def test_delete(fake_synonym_set: SynonymSet) -> None: def test_actual_retrieve( - actual_synonym_sets: "SynonymSets", # type: ignore[name-defined] + actual_synonym_sets: SynonymSets, delete_all_synonym_sets: None, create_synonym_set: None, ) -> None: @@ -116,7 +117,7 @@ def test_actual_retrieve( def test_actual_delete( - actual_synonym_sets: "SynonymSets", # type: ignore[name-defined] + actual_synonym_sets: SynonymSets, create_synonym_set: None, ) -> None: """Test that the SynonymSet object can delete a synonym set from Typesense Server.""" From 6a15127c2a23ac45a0879ec8110287252ac1334e Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 12:35:04 +0200 Subject: [PATCH 08/33] fix(test): add `truncate_len` to expected schemas in collection tests --- tests/collection_test.py | 7 +++---- tests/collections_test.py | 4 ++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/collection_test.py b/tests/collection_test.py index d01ae2f..56c4429 100644 --- a/tests/collection_test.py +++ b/tests/collection_test.py @@ -204,6 +204,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, { @@ -217,6 +218,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, ], @@ -245,10 +247,7 @@ def test_actual_update( expected: CollectionSchema = { "fields": [ - { - "name": "num_locations", - "type": "int32", - }, + {"name": "num_locations", "truncate_len": 100, "type": "int32"}, ], } diff --git a/tests/collections_test.py b/tests/collections_test.py index a52c44d..55142ae 100644 --- a/tests/collections_test.py +++ b/tests/collections_test.py @@ -203,6 +203,7 @@ def test_actual_create(actual_collections: Collections, delete_all: None) -> Non "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, { @@ -216,6 +217,7 @@ def test_actual_create(actual_collections: Collections, delete_all: None) -> Non "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, ], @@ -273,6 +275,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, { @@ -286,6 +289,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, ], From 427be744127687324317f8738d37749530210d91 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 12:35:36 +0200 Subject: [PATCH 09/33] fix(test): check for versions not prefixed with `v` on skip util --- tests/utils/version.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/utils/version.py b/tests/utils/version.py index ba3ca93..a7d375c 100644 --- a/tests/utils/version.py +++ b/tests/utils/version.py @@ -10,8 +10,13 @@ def is_v30_or_above(client: Client) -> bool: if version == "nightly": return True try: - numbered = str(version).split("v")[1] - return int(numbered) >= 30 + version_str = str(version) + if version_str.startswith("v"): + numbered = version_str.split("v", 1)[1] + else: + numbered = version_str + major_version = numbered.split(".", 1)[0] + return int(major_version) >= 30 except Exception: return False except Exception: From 9aeebc5f20090bf323f3ec3d48cd6d544809ad73 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:13:28 +0200 Subject: [PATCH 10/33] chore: lint --- src/typesense/analytics.py | 12 +----------- src/typesense/analytics_rule.py | 9 --------- src/typesense/curation_set.py | 3 +-- src/typesense/curation_sets.py | 11 +++-------- src/typesense/synonym.py | 2 +- src/typesense/synonym_set.py | 4 +++- src/typesense/synonym_sets.py | 7 ++----- 7 files changed, 11 insertions(+), 37 deletions(-) diff --git a/src/typesense/analytics.py b/src/typesense/analytics.py index 3463748..c4a09e2 100644 --- a/src/typesense/analytics.py +++ b/src/typesense/analytics.py @@ -1,15 +1,8 @@ """Client for Typesense Analytics module.""" -import sys - -if sys.version_info >= (3, 11): - import typing -else: - import typing_extensions as typing - -from typesense.api_call import ApiCall from typesense.analytics_events import AnalyticsEvents from typesense.analytics_rules import AnalyticsRules +from typesense.api_call import ApiCall class Analytics: @@ -19,6 +12,3 @@ def __init__(self, api_call: ApiCall) -> None: self.api_call = api_call self.rules = AnalyticsRules(api_call) self.events = AnalyticsEvents(api_call) - - - diff --git a/src/typesense/analytics_rule.py b/src/typesense/analytics_rule.py index d9c21b2..fba11ce 100644 --- a/src/typesense/analytics_rule.py +++ b/src/typesense/analytics_rule.py @@ -1,12 +1,5 @@ """Per-rule client for Analytics rules operations.""" -import sys - -if sys.version_info >= (3, 11): - import typing -else: - import typing_extensions as typing - from typesense.api_call import ApiCall from typesense.types.analytics import AnalyticsRule @@ -36,5 +29,3 @@ def delete(self) -> AnalyticsRule: entity_type=AnalyticsRule, ) return response - - diff --git a/src/typesense/curation_set.py b/src/typesense/curation_set.py index f0db7e4..3828161 100644 --- a/src/typesense/curation_set.py +++ b/src/typesense/curation_set.py @@ -11,7 +11,6 @@ from typesense.types.curation_set import ( CurationSetSchema, CurationSetDeleteSchema, - CurationSetUpsertSchema, CurationSetListItemResponseSchema, CurationItemSchema, CurationItemDeleteSchema, @@ -61,7 +60,7 @@ def list_items( } # Filter out None values to avoid sending them clean_params: typing.Dict[str, int] = { - k: v for k, v in params.items() if v is not None # type: ignore[dict-item] + k: v for k, v in params.items() if v is not None } response: CurationSetListItemResponseSchema = self.api_call.get( self._items_path, diff --git a/src/typesense/curation_sets.py b/src/typesense/curation_sets.py index d257f42..b13303e 100644 --- a/src/typesense/curation_sets.py +++ b/src/typesense/curation_sets.py @@ -8,14 +8,11 @@ import typing_extensions as typing from typesense.api_call import ApiCall +from typesense.curation_set import CurationSet from typesense.types.curation_set import ( CurationSetSchema, - CurationSetUpsertSchema, CurationSetsListResponseSchema, - CurationSetListItemResponseSchema, - CurationItemDeleteSchema, - CurationSetDeleteSchema, - CurationItemSchema, + CurationSetUpsertSchema, ) @@ -33,7 +30,7 @@ def retrieve(self) -> CurationSetsListResponseSchema: ) return response - def __getitem__(self, curation_set_name: str) -> "CurationSet": + def __getitem__(self, curation_set_name: str) -> CurationSet: from typesense.curation_set import CurationSet as PerSet return PerSet(self.api_call, curation_set_name) @@ -49,5 +46,3 @@ def upsert( entity_type=CurationSetSchema, ) return response - - diff --git a/src/typesense/synonym.py b/src/typesense/synonym.py index 4d5b73b..53f9bd3 100644 --- a/src/typesense/synonym.py +++ b/src/typesense/synonym.py @@ -23,9 +23,9 @@ from typesense.api_call import ApiCall from typesense.logger import logger +from typesense.types.synonym import SynonymDeleteSchema, SynonymSchema _synonym_deprecation_warned = False -from typesense.types.synonym import SynonymDeleteSchema, SynonymSchema class Synonym: diff --git a/src/typesense/synonym_set.py b/src/typesense/synonym_set.py index daa9c7d..e00401c 100644 --- a/src/typesense/synonym_set.py +++ b/src/typesense/synonym_set.py @@ -57,7 +57,9 @@ def list_items( "offset": offset, } clean_params: typing.Dict[str, int] = { - k: v for k, v in params.items() if v is not None # type: ignore[dict-item] + k: v + for k, v in params.items() + if v is not None } response: typing.List[SynonymItemSchema] = self.api_call.get( self._items_path, diff --git a/src/typesense/synonym_sets.py b/src/typesense/synonym_sets.py index a1a38e5..543e77c 100644 --- a/src/typesense/synonym_sets.py +++ b/src/typesense/synonym_sets.py @@ -8,10 +8,9 @@ import typing_extensions as typing from typesense.api_call import ApiCall +from typesense.synonym_set import SynonymSet from typesense.types.synonym_set import ( SynonymSetCreateSchema, - SynonymSetDeleteSchema, - SynonymSetRetrieveSchema, SynonymSetSchema, ) @@ -30,7 +29,7 @@ def retrieve(self) -> typing.List[SynonymSetSchema]: ) return response - def __getitem__(self, synonym_set_name: str) -> "SynonymSet": + def __getitem__(self, synonym_set_name: str) -> SynonymSet: from typesense.synonym_set import SynonymSet as PerSet return PerSet(self.api_call, synonym_set_name) @@ -46,5 +45,3 @@ def upsert( entity_type=SynonymSetSchema, ) return response - - From dd3e2869623599344f4d1c9fbfe7d230976391d8 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:13:44 +0200 Subject: [PATCH 11/33] fix(curation_set): add discriminated union types for curation sets --- src/typesense/types/curation_set.py | 51 +++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/typesense/types/curation_set.py b/src/typesense/types/curation_set.py index f3d3729..a19ee0f 100644 --- a/src/typesense/types/curation_set.py +++ b/src/typesense/types/curation_set.py @@ -25,18 +25,47 @@ class CurationExcludeSchema(typing.TypedDict): id: str -class CurationRuleSchema(typing.TypedDict, total=False): +class CurationRuleTagsSchema(typing.TypedDict): """ - Schema representing rule conditions for a curation item. + Schema for a curation rule using tags. """ - query: typing.NotRequired[str] - match: typing.NotRequired[typing.Literal["exact", "contains"]] - filter_by: typing.NotRequired[str] - tags: typing.NotRequired[typing.List[str]] + tags: typing.List[str] + + +class CurationRuleQuerySchema(typing.TypedDict): + """ + Schema for a curation rule using query and match. + """ + + query: str + match: typing.Literal["exact", "contains"] -class CurationItemSchema(typing.TypedDict, total=False): +class CurationRuleFilterBySchema(typing.TypedDict): + """ + Schema for a curation rule using filter_by. + """ + + filter_by: str + + +CurationRuleSchema = typing.Union[ + CurationRuleTagsSchema, + CurationRuleQuerySchema, + CurationRuleFilterBySchema, +] +""" +Schema representing rule conditions for a curation item. + +A curation rule must be exactly one of: +- A tags-based rule: `{ tags: string[] }` +- A query-based rule: `{ query: string; match: "exact" | "contains" }` +- A filter_by-based rule: `{ filter_by: string }` +""" + + +class CurationItemSchema(typing.TypedDict): """ Schema for a single curation item (aka CurationObject in the API). """ @@ -51,7 +80,9 @@ class CurationItemSchema(typing.TypedDict, total=False): remove_matched_tokens: typing.NotRequired[bool] filter_curated_hits: typing.NotRequired[bool] stop_processing: typing.NotRequired[bool] - metadata: typing.Dict[str, typing.Any] + effective_from_ts: typing.NotRequired[int] + effective_to_ts: typing.NotRequired[int] + metadata: typing.NotRequired[typing.Dict[str, typing.Any]] class CurationSetUpsertSchema(typing.TypedDict): @@ -62,12 +93,12 @@ class CurationSetUpsertSchema(typing.TypedDict): items: typing.List[CurationItemSchema] -class CurationSetSchema(CurationSetUpsertSchema): +class CurationSetSchema(CurationSetUpsertSchema, total=False): """ Response schema for a curation set. """ - name: str + name: typing.NotRequired[str] class CurationSetsListEntrySchema(typing.TypedDict): From 59c850d655ee8269ba19bc17465426fc95615af2 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:27:34 +0200 Subject: [PATCH 12/33] fix(analytics): rename analytics rule type to schema to avoid mypy issues --- src/typesense/analytics_rule.py | 6 +++--- src/typesense/analytics_rules.py | 37 +++++++++++++++++--------------- src/typesense/types/analytics.py | 5 ++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/typesense/analytics_rule.py b/src/typesense/analytics_rule.py index fba11ce..86b516d 100644 --- a/src/typesense/analytics_rule.py +++ b/src/typesense/analytics_rule.py @@ -1,7 +1,7 @@ """Per-rule client for Analytics rules operations.""" from typesense.api_call import ApiCall -from typesense.types.analytics import AnalyticsRule +from typesense.types.analytics import AnalyticsRuleSchema class AnalyticsRule: @@ -15,7 +15,7 @@ def _endpoint_path(self) -> str: return "/".join([AnalyticsRules.resource_path, self.rule_name]) - def retrieve(self) -> AnalyticsRule: + def retrieve(self) -> AnalyticsRuleSchema: response: AnalyticsRule = self.api_call.get( self._endpoint_path, as_json=True, @@ -23,7 +23,7 @@ def retrieve(self) -> AnalyticsRule: ) return response - def delete(self) -> AnalyticsRule: + def delete(self) -> AnalyticsRuleSchema: response: AnalyticsRule = self.api_call.delete( self._endpoint_path, entity_type=AnalyticsRule, diff --git a/src/typesense/analytics_rules.py b/src/typesense/analytics_rules.py index 2097e0b..a95dc60 100644 --- a/src/typesense/analytics_rules.py +++ b/src/typesense/analytics_rules.py @@ -7,10 +7,11 @@ else: import typing_extensions as typing +from typesense.analytics_rule import AnalyticsRule from typesense.api_call import ApiCall from typesense.types.analytics import ( - AnalyticsRule, AnalyticsRuleCreate, + AnalyticsRuleSchema, AnalyticsRuleUpdate, ) @@ -20,40 +21,42 @@ class AnalyticsRules(object): def __init__(self, api_call: ApiCall) -> None: self.api_call = api_call - self.rules: typing.Dict[str, "AnalyticsRule"] = {} + self.rules: typing.Dict[str, AnalyticsRuleSchema] = {} - def __getitem__(self, rule_name: str) -> "AnalyticsRule": + def __getitem__(self, rule_name: str) -> AnalyticsRuleSchema: if rule_name not in self.rules: - from typesense.analytics_rule import AnalyticsRule as PerRule + self.rules[rule_name] = AnalyticsRule(self.api_call, rule_name) + return self.rules[rule_name] - self.rules[rule_name] = PerRule(self.api_call, rule_name) - return typing.cast("AnalyticsRule", self.rules[rule_name]) - - def create(self, rule: AnalyticsRuleCreate) -> AnalyticsRule: - response: AnalyticsRule = self.api_call.post( + def create(self, rule: AnalyticsRuleCreate) -> AnalyticsRuleSchema: + response: AnalyticsRuleSchema = self.api_call.post( AnalyticsRules.resource_path, body=rule, as_json=True, - entity_type=AnalyticsRule, + entity_type=AnalyticsRuleSchema, ) return response - def retrieve(self, *, rule_tag: typing.Union[str, None] = None) -> typing.List[AnalyticsRule]: + def retrieve( + self, *, rule_tag: typing.Union[str, None] = None + ) -> typing.List[AnalyticsRuleSchema]: params: typing.Dict[str, str] = {} if rule_tag: params["rule_tag"] = rule_tag - response: typing.List[AnalyticsRule] = self.api_call.get( + response: typing.List[AnalyticsRuleSchema] = self.api_call.get( AnalyticsRules.resource_path, params=params if params else None, as_json=True, - entity_type=typing.List[AnalyticsRule], + entity_type=typing.List[AnalyticsRuleSchema], ) return response - def upsert(self, rule_name: str, update: AnalyticsRuleUpdate) -> AnalyticsRule: - response: AnalyticsRule = self.api_call.put( + def upsert( + self, rule_name: str, update: AnalyticsRuleUpdate + ) -> AnalyticsRuleSchema: + response: AnalyticsRuleSchema = self.api_call.put( "/".join([AnalyticsRules.resource_path, rule_name]), body=update, - entity_type=AnalyticsRule, + entity_type=AnalyticsRuleSchema, ) - return response \ No newline at end of file + return response diff --git a/src/typesense/types/analytics.py b/src/typesense/types/analytics.py index 5f5d133..b442f7e 100644 --- a/src/typesense/types/analytics.py +++ b/src/typesense/types/analytics.py @@ -51,6 +51,7 @@ class AnalyticsStatus(typing.TypedDict, total=False): # Rules + class AnalyticsRuleParams(typing.TypedDict, total=False): destination_collection: typing.NotRequired[str] limit: typing.NotRequired[int] @@ -76,7 +77,5 @@ class AnalyticsRuleUpdate(typing.TypedDict, total=False): params: AnalyticsRuleParams -class AnalyticsRule(AnalyticsRuleCreate, total=False): +class AnalyticsRuleSchema(AnalyticsRuleCreate, total=False): pass - - From 8e11da7011a518035ac665665723aa797fdb1431 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:32:41 +0200 Subject: [PATCH 13/33] fix(synonym_set): fix return type for delete_item method --- src/typesense/synonym_set.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/typesense/synonym_set.py b/src/typesense/synonym_set.py index e00401c..0828791 100644 --- a/src/typesense/synonym_set.py +++ b/src/typesense/synonym_set.py @@ -85,11 +85,10 @@ def upsert_item(self, item_id: str, item: SynonymItemSchema) -> SynonymItemSchem ) return response - def delete_item(self, item_id: str) -> typing.Dict[str, str]: + def delete_item(self, item_id: str) -> SynonymItemDeleteSchema: # API returns {"id": "..."} for delete; openapi defines SynonymItemDeleteResponse with name but for items it's id response: SynonymItemDeleteSchema = self.api_call.delete( - "/".join([self._items_path, item_id]), - entity_type=typing.Dict[str, str], + "/".join([self._items_path, item_id]), entity_type=SynonymItemDeleteSchema ) return response From 7a0dcc270097cc34fb111cafd74dddb0c1358b7e Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:33:22 +0200 Subject: [PATCH 14/33] test(curation_set): add integration tests for curation set --- tests/curation_set_test.py | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/curation_set_test.py b/tests/curation_set_test.py index d975b4c..46ed37a 100644 --- a/tests/curation_set_test.py +++ b/tests/curation_set_test.py @@ -8,6 +8,7 @@ from tests.utils.version import is_v30_or_above from typesense.client import Client from typesense.curation_set import CurationSet +from typesense.curation_sets import CurationSets from typesense.types.curation_set import ( CurationItemDeleteSchema, CurationItemSchema, @@ -121,3 +122,48 @@ def test_delete_item(fake_curation_set: CurationSet) -> None: assert res == json_response +def test_actual_retrieve( + actual_curation_sets: CurationSets, + delete_all_curation_sets: None, + create_curation_set: None, +) -> None: + """Test that the CurationSet object can retrieve a curation set from Typesense Server.""" + response = actual_curation_sets["products"].retrieve() + + assert response == { + "items": [ + { + "excludes": [ + { + "id": "999", + }, + ], + "filter_curated_hits": False, + "id": "rule-1", + "includes": [ + { + "id": "123", + "position": 1, + }, + ], + "remove_matched_tokens": False, + "rule": { + "match": "contains", + "query": "shoe", + }, + "stop_processing": True, + }, + ], + "name": "products", + } + + +def test_actual_delete( + actual_curation_sets: CurationSets, + create_curation_set: None, +) -> None: + """Test that the CurationSet object can delete a curation set from Typesense Server.""" + response = actual_curation_sets["products"].delete() + + print(response) + assert response == {"name": "products"} From 972b9a139c83f1d4b1ca2a8c62f4c4b5d32e19b1 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:33:32 +0200 Subject: [PATCH 15/33] test(curation_sets): add integration tests for curation sets --- tests/curation_sets_test.py | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/curation_sets_test.py b/tests/curation_sets_test.py index 5f4a270..1d7d92a 100644 --- a/tests/curation_sets_test.py +++ b/tests/curation_sets_test.py @@ -8,6 +8,7 @@ from tests.utils.object_assertions import ( assert_match_object, assert_object_lists_match, + assert_to_contain_object, ) from tests.utils.version import is_v30_or_above from typesense.api_call import ApiCall @@ -110,3 +111,65 @@ def test_upsert(fake_curation_sets: CurationSets) -> None: assert mock.last_request.json() == payload +def test_actual_upsert( + actual_curation_sets: CurationSets, + delete_all_curation_sets: None, +) -> None: + """Test that the CurationSets object can upsert a curation set on Typesense Server.""" + response = actual_curation_sets.upsert( + "products", + { + "items": [ + { + "id": "rule-1", + "rule": {"query": "shoe", "match": "contains"}, + "includes": [{"id": "123", "position": 1}], + "excludes": [{"id": "999"}], + } + ] + }, + ) + + assert response == { + "items": [ + { + "excludes": [ + { + "id": "999", + }, + ], + "filter_curated_hits": False, + "id": "rule-1", + "includes": [ + { + "id": "123", + "position": 1, + }, + ], + "remove_matched_tokens": False, + "rule": { + "match": "contains", + "query": "shoe", + }, + "stop_processing": True, + }, + ], + "name": "products", + } + + +def test_actual_retrieve( + actual_curation_sets: CurationSets, + delete_all_curation_sets: None, + create_curation_set: None, +) -> None: + """Test that the CurationSets object can retrieve curation sets from Typesense Server.""" + response = actual_curation_sets.retrieve() + + assert isinstance(response, list) + assert_to_contain_object( + response[0], + { + "name": "products", + }, + ) From 0861103b0b97c19b97444a90937f913912a51a7b Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:41:12 +0200 Subject: [PATCH 16/33] ci: upgrade typesense version to v30 on ci --- .github/workflows/test-and-lint.yml | 30 ++++++------ tests/analytics_events_test.py | 48 ++++++++++---------- tests/analytics_rule_test.py | 13 +++--- tests/analytics_rule_v1_test.py | 20 ++++---- tests/analytics_rules_test.py | 13 +++--- tests/analytics_rules_v1_test.py | 19 ++++---- tests/analytics_test.py | 13 +++++- tests/analytics_v1_test.py | 15 ++++-- tests/api_call_test.py | 8 ++-- tests/collections_test.py | 6 +-- tests/curation_set_test.py | 5 +- tests/curation_sets_test.py | 9 +--- tests/fixtures/analytics_fixtures.py | 3 +- tests/fixtures/analytics_rule_v1_fixtures.py | 2 - tests/fixtures/curation_set_fixtures.py | 2 - tests/fixtures/synonym_set_fixtures.py | 2 - tests/metrics_test.py | 2 +- tests/override_test.py | 10 ++-- tests/overrides_test.py | 11 +++-- tests/synonym_set_items_test.py | 6 +-- tests/synonym_set_test.py | 14 ++---- tests/synonym_sets_test.py | 10 +--- tests/synonym_test.py | 4 +- tests/synonyms_test.py | 4 +- tests/utils/version.py | 2 - 25 files changed, 131 insertions(+), 140 deletions(-) diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 678254e..9552400 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -12,22 +12,24 @@ jobs: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] - services: - typesense: - image: typesense/typesense:28.0 - ports: - - 8108:8108 - volumes: - - /tmp/typesense-data:/data - - /tmp/typesense-analytics:/analytics - env: - TYPESENSE_API_KEY: xyz - TYPESENSE_DATA_DIR: /data - TYPESENSE_ENABLE_CORS: true - TYPESENSE_ANALYTICS_DIR: /analytics - TYPESENSE_ENABLE_SEARCH_ANALYTICS: true steps: + - name: Start Typesense + run: | + docker run -d \ + -p 8108:8108 \ + --name typesense \ + -v /tmp/typesense-data:/data \ + -v /tmp/typesense-analytics-data:/analytics-data \ + typesense/typesense:30.0.alpha1 \ + --api-key=xyz \ + --data-dir=/data \ + --enable-search-analytics=true \ + --analytics-dir=/analytics-data \ + --analytics-flush-interval=60 \ + --analytics-minute-rate-limit=50 \ + --enable-cors + - name: Wait for Typesense run: | timeout 20 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:8108/health)" != "200" ]]; do sleep 1; done' || false diff --git a/tests/analytics_events_test.py b/tests/analytics_events_test.py index 81af690..34243ba 100644 --- a/tests/analytics_events_test.py +++ b/tests/analytics_events_test.py @@ -1,27 +1,33 @@ """Tests for Analytics events endpoints (client.analytics.events).""" + from __future__ import annotations import pytest +import requests_mock from tests.utils.version import is_v30_or_above from typesense.client import Client -import requests_mock - from typesense.types.analytics import AnalyticsEvent - pytestmark = pytest.mark.skipif( not is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), reason="Run analytics events tests only on v30+", ) -def test_actual_create_event(actual_client: Client, delete_all: None, create_collection: None, delete_all_analytics_rules: None) -> None: +def test_actual_create_event( + actual_client: Client, + delete_all: None, + create_collection: None, + delete_all_analytics_rules: None, +) -> None: actual_client.analytics.rules.create( { "name": "company_analytics_rule", @@ -61,7 +67,9 @@ def test_status(actual_client: Client, delete_all: None) -> None: assert isinstance(status, dict) -def test_retrieve_events(actual_client: Client, delete_all: None, delete_all_analytics_rules: None) -> None: +def test_retrieve_events( + actual_client: Client, delete_all: None, delete_all_analytics_rules: None +) -> None: actual_client.analytics.rules.create( { "name": "company_analytics_rule", @@ -89,19 +97,12 @@ def test_retrieve_events(actual_client: Client, delete_all: None, delete_all_ana assert "events" in result - -def test_retrieve_events(fake_client: Client) -> None: - with requests_mock.Mocker() as mock: - mock.get( - "http://nearest:8108/analytics/events", - json={"events": [{"name": "company_analytics_rule"}]}, - ) - result = fake_client.analytics.events.retrieve( - user_id="user-1", name="company_analytics_rule", n=10 - ) - assert "events" in result - -def test_acutal_retrieve_events(actual_client: Client, delete_all: None, create_collection: None, delete_all_analytics_rules: None) -> None: +def test_acutal_retrieve_events( + actual_client: Client, + delete_all: None, + create_collection: None, + delete_all_analytics_rules: None, +) -> None: actual_client.analytics.rules.create( { "name": "company_analytics_rule", @@ -126,6 +127,7 @@ def test_acutal_retrieve_events(actual_client: Client, delete_all: None, create_ ) assert "events" in result + def test_acutal_flush(actual_client: Client, delete_all: None) -> None: resp = actual_client.analytics.events.flush() assert resp["ok"] in [True, False] @@ -136,5 +138,3 @@ def test_flush(fake_client: Client) -> None: mock.post("http://nearest:8108/analytics/flush", json={"ok": True}) resp = fake_client.analytics.events.flush() assert resp["ok"] is True - - diff --git a/tests/analytics_rule_test.py b/tests/analytics_rule_test.py index 68b9122..199e7ae 100644 --- a/tests/analytics_rule_test.py +++ b/tests/analytics_rule_test.py @@ -1,4 +1,5 @@ """Unit tests for per-rule AnalyticsRule operations.""" + from __future__ import annotations import pytest @@ -12,10 +13,12 @@ pytestmark = pytest.mark.skipif( not is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), reason="Run analytics tests only on v30+", ) @@ -63,5 +66,3 @@ def test_actual_rule_delete( ) -> None: resp = actual_analytics_rules["company_analytics_rule"].delete() assert resp["name"] == "company_analytics_rule" - - diff --git a/tests/analytics_rule_v1_test.py b/tests/analytics_rule_v1_test.py index 4e3534c..d30b002 100644 --- a/tests/analytics_rule_v1_test.py +++ b/tests/analytics_rule_v1_test.py @@ -1,4 +1,5 @@ """Tests for the AnalyticsRuleV1 class.""" + from __future__ import annotations import pytest @@ -14,14 +15,17 @@ pytestmark = pytest.mark.skipif( is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), - reason="Skip AnalyticsV1 tests on v30+" + reason="Skip AnalyticsV1 tests on v30+", ) + def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsRuleV1 object is initialized correctly.""" analytics_rule = AnalyticsRuleV1(fake_api_call, "company_analytics_rule") @@ -42,7 +46,6 @@ def test_init(fake_api_call: ApiCall) -> None: ) - def test_retrieve(fake_analytics_rule: AnalyticsRuleV1) -> None: """Test that the AnalyticsRuleV1 object can retrieve an analytics_rule.""" json_response: RuleSchemaForQueries = { @@ -73,7 +76,6 @@ def test_retrieve(fake_analytics_rule: AnalyticsRuleV1) -> None: assert response == json_response - def test_delete(fake_analytics_rule: AnalyticsRuleV1) -> None: """Test that the AnalyticsRuleV1 object can delete an analytics_rule.""" json_response: RuleDeleteSchema = { @@ -96,7 +98,6 @@ def test_delete(fake_analytics_rule: AnalyticsRuleV1) -> None: assert response == json_response - def test_actual_retrieve( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -119,7 +120,6 @@ def test_actual_retrieve( assert response == expected - def test_actual_delete( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -133,5 +133,3 @@ def test_actual_delete( "name": "company_analytics_rule", } assert response == expected - - diff --git a/tests/analytics_rules_test.py b/tests/analytics_rules_test.py index 81fce0b..70f16f5 100644 --- a/tests/analytics_rules_test.py +++ b/tests/analytics_rules_test.py @@ -1,4 +1,5 @@ """Tests for v30 Analytics Rules endpoints (client.analytics.rules).""" + from __future__ import annotations import pytest @@ -13,10 +14,12 @@ pytestmark = pytest.mark.skipif( not is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), reason="Run v30 analytics tests only on v30+", ) @@ -130,5 +133,3 @@ def test_actual_retrieve( rules = actual_analytics_rules.retrieve() assert isinstance(rules, list) assert any(r.get("name") == "company_analytics_rule" for r in rules) - - diff --git a/tests/analytics_rules_v1_test.py b/tests/analytics_rules_v1_test.py index 6ea2d91..7eb2749 100644 --- a/tests/analytics_rules_v1_test.py +++ b/tests/analytics_rules_v1_test.py @@ -1,4 +1,5 @@ """Tests for the AnalyticsRulesV1 class.""" + from __future__ import annotations import pytest @@ -17,14 +18,17 @@ pytestmark = pytest.mark.skipif( is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), - reason="Skip AnalyticsV1 tests on v30+" + reason="Skip AnalyticsV1 tests on v30+", ) + def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsRulesV1 object is initialized correctly.""" analytics_rules = AnalyticsRulesV1(fake_api_call) @@ -150,7 +154,6 @@ def test_create(fake_analytics_rules: AnalyticsRulesV1) -> None: } - def test_actual_create( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -182,7 +185,6 @@ def test_actual_create( } - def test_actual_update( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -213,7 +215,6 @@ def test_actual_update( } - def test_actual_retrieve( actual_analytics_rules: AnalyticsRulesV1, delete_all: None, @@ -235,5 +236,3 @@ def test_actual_retrieve( "type": "nohits_queries", }, ) - - diff --git a/tests/analytics_test.py b/tests/analytics_test.py index a7e2276..2ff12b6 100644 --- a/tests/analytics_test.py +++ b/tests/analytics_test.py @@ -1,4 +1,5 @@ """Tests for the AnalyticsV1 class.""" + import pytest from tests.utils.version import is_v30_or_above from typesense.client import Client @@ -7,7 +8,17 @@ from typesense.api_call import ApiCall -@pytest.mark.skipif(not is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +@pytest.mark.skipif( + not is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) + ), + reason="Skip AnalyticsV1 tests on v30+", +) def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsV1 object is initialized correctly.""" analytics = Analytics(fake_api_call) diff --git a/tests/analytics_v1_test.py b/tests/analytics_v1_test.py index 50b9339..f617b7b 100644 --- a/tests/analytics_v1_test.py +++ b/tests/analytics_v1_test.py @@ -1,4 +1,5 @@ """Tests for the AnalyticsV1 class.""" + import pytest from tests.utils.version import is_v30_or_above from typesense.client import Client @@ -7,7 +8,17 @@ from typesense.api_call import ApiCall -@pytest.mark.skipif(is_v30_or_above(Client({"api_key": "xyz", "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}]})), reason="Skip AnalyticsV1 tests on v30+") +@pytest.mark.skipif( + is_v30_or_above( + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) + ), + reason="Skip AnalyticsV1 tests on v30+", +) def test_init(fake_api_call: ApiCall) -> None: """Test that the AnalyticsV1 object is initialized correctly.""" analytics = AnalyticsV1(fake_api_call) @@ -23,5 +34,3 @@ def test_init(fake_api_call: ApiCall) -> None: ) assert not analytics.rules.rules - - diff --git a/tests/api_call_test.py b/tests/api_call_test.py index e13c056..96acadf 100644 --- a/tests/api_call_test.py +++ b/tests/api_call_test.py @@ -100,7 +100,7 @@ def test_get_error_message_with_invalid_json() -> None: response.status_code = 400 # Set an invalid JSON string that would cause JSONDecodeError response._content = b'{"message": "Error occurred", "details": {"key": "value"' - + error_message = RequestHandler._get_error_message(response) assert "API error: Invalid JSON response:" in error_message assert '{"message": "Error occurred", "details": {"key": "value"' in error_message @@ -112,7 +112,7 @@ def test_get_error_message_with_valid_json() -> None: response.headers["Content-Type"] = "application/json" response.status_code = 400 response._content = b'{"message": "Error occurred", "details": {"key": "value"}}' - + error_message = RequestHandler._get_error_message(response) assert error_message == "Error occurred" @@ -122,8 +122,8 @@ def test_get_error_message_with_non_json_content_type() -> None: response = requests.Response() response.headers["Content-Type"] = "text/plain" response.status_code = 400 - response._content = b'Not a JSON content' - + response._content = b"Not a JSON content" + error_message = RequestHandler._get_error_message(response) assert error_message == "API error." diff --git a/tests/collections_test.py b/tests/collections_test.py index 55142ae..d742652 100644 --- a/tests/collections_test.py +++ b/tests/collections_test.py @@ -86,7 +86,7 @@ def test_retrieve(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], }, { "created_at": 1619711488, @@ -106,7 +106,7 @@ def test_retrieve(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], }, ] with requests_mock.Mocker() as mock: @@ -140,7 +140,7 @@ def test_create(fake_collections: Collections) -> None: "num_documents": 0, "symbols_to_index": [], "token_separators": [], - "synonym_sets": [] + "synonym_sets": [], } with requests_mock.Mocker() as mock: diff --git a/tests/curation_set_test.py b/tests/curation_set_test.py index 46ed37a..d8c4075 100644 --- a/tests/curation_set_test.py +++ b/tests/curation_set_test.py @@ -17,15 +17,12 @@ CurationSetSchema, ) - pytestmark = pytest.mark.skipif( not is_v30_or_above( Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), diff --git a/tests/curation_sets_test.py b/tests/curation_sets_test.py index 1d7d92a..82091d5 100644 --- a/tests/curation_sets_test.py +++ b/tests/curation_sets_test.py @@ -16,15 +16,12 @@ from typesense.curation_sets import CurationSets from typesense.types.curation_set import CurationSetSchema, CurationSetUpsertSchema - pytestmark = pytest.mark.skipif( not is_v30_or_above( Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), @@ -105,9 +102,7 @@ def test_upsert(fake_curation_sets: CurationSets) -> None: assert mock.call_count == 1 assert mock.called is True assert mock.last_request.method == "PUT" - assert ( - mock.last_request.url == "http://nearest:8108/curation_sets/products" - ) + assert mock.last_request.url == "http://nearest:8108/curation_sets/products" assert mock.last_request.json() == payload diff --git a/tests/fixtures/analytics_fixtures.py b/tests/fixtures/analytics_fixtures.py index a95c8b5..9097294 100644 --- a/tests/fixtures/analytics_fixtures.py +++ b/tests/fixtures/analytics_fixtures.py @@ -66,6 +66,7 @@ def fake_analytics_rule_fixture(fake_api_call: ApiCall) -> AnalyticsRule: """Return an AnalyticsRule object with test values.""" return AnalyticsRule(fake_api_call, "company_analytics_rule") + @pytest.fixture(scope="function", name="create_query_collection") def create_query_collection_fixture() -> None: """Create a query collection for analytics rules in the Typesense server.""" @@ -91,4 +92,4 @@ def create_query_collection_fixture() -> None: json=query_collection_data, timeout=3, ) - response.raise_for_status() \ No newline at end of file + response.raise_for_status() diff --git a/tests/fixtures/analytics_rule_v1_fixtures.py b/tests/fixtures/analytics_rule_v1_fixtures.py index 44994eb..0dca1d0 100644 --- a/tests/fixtures/analytics_rule_v1_fixtures.py +++ b/tests/fixtures/analytics_rule_v1_fixtures.py @@ -66,5 +66,3 @@ def actual_analytics_rules_v1_fixture(actual_api_call: ApiCall) -> AnalyticsRule def fake_analytics_rule_v1_fixture(fake_api_call: ApiCall) -> AnalyticsRuleV1: """Return a AnalyticsRule object with test values.""" return AnalyticsRuleV1(fake_api_call, "company_analytics_rule") - - diff --git a/tests/fixtures/curation_set_fixtures.py b/tests/fixtures/curation_set_fixtures.py index 6ab184c..3fc61b5 100644 --- a/tests/fixtures/curation_set_fixtures.py +++ b/tests/fixtures/curation_set_fixtures.py @@ -69,5 +69,3 @@ def fake_curation_sets_fixture(fake_api_call: ApiCall) -> CurationSets: def fake_curation_set_fixture(fake_api_call: ApiCall) -> CurationSet: """Return a CurationSet object with test values.""" return CurationSet(fake_api_call, "products") - - diff --git a/tests/fixtures/synonym_set_fixtures.py b/tests/fixtures/synonym_set_fixtures.py index c4c4341..41ad3bb 100644 --- a/tests/fixtures/synonym_set_fixtures.py +++ b/tests/fixtures/synonym_set_fixtures.py @@ -69,5 +69,3 @@ def fake_synonym_sets_fixture(fake_api_call: ApiCall) -> SynonymSets: def fake_synonym_set_fixture(fake_api_call: ApiCall) -> SynonymSet: """Return a SynonymSet object with test values.""" return SynonymSet(fake_api_call, "test-set") - - diff --git a/tests/metrics_test.py b/tests/metrics_test.py index 1e1ea47..01bb9fa 100644 --- a/tests/metrics_test.py +++ b/tests/metrics_test.py @@ -23,4 +23,4 @@ def test_actual_retrieve(actual_metrics: Metrics) -> None: assert "typesense_memory_mapped_bytes" in response assert "typesense_memory_metadata_bytes" in response assert "typesense_memory_resident_bytes" in response - assert "typesense_memory_retained_bytes" in response \ No newline at end of file + assert "typesense_memory_retained_bytes" in response diff --git a/tests/override_test.py b/tests/override_test.py index 0886bc5..eba0dee 100644 --- a/tests/override_test.py +++ b/tests/override_test.py @@ -20,10 +20,12 @@ pytestmark = pytest.mark.skipif( is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), reason="Run override tests only on less than v30", ) diff --git a/tests/overrides_test.py b/tests/overrides_test.py index 4593961..e543bea 100644 --- a/tests/overrides_test.py +++ b/tests/overrides_test.py @@ -18,14 +18,17 @@ pytestmark = pytest.mark.skipif( is_v30_or_above( - Client({ - "api_key": "xyz", - "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], - }) + Client( + { + "api_key": "xyz", + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], + } + ) ), reason="Run override tests only on less than v30", ) + def test_init(fake_api_call: ApiCall) -> None: """Test that the Overrides object is initialized correctly.""" overrides = Overrides(fake_api_call, "companies") diff --git a/tests/synonym_set_items_test.py b/tests/synonym_set_items_test.py index 0fb55d7..2cc1dc6 100644 --- a/tests/synonym_set_items_test.py +++ b/tests/synonym_set_items_test.py @@ -19,9 +19,7 @@ Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), @@ -81,5 +79,3 @@ def test_delete_item(fake_synonym_set: SynonymSet) -> None: ) res = fake_synonym_set.delete_item("nike") assert res == json_response - - diff --git a/tests/synonym_set_test.py b/tests/synonym_set_test.py index ee6650d..b64aa5c 100644 --- a/tests/synonym_set_test.py +++ b/tests/synonym_set_test.py @@ -19,9 +19,7 @@ Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), @@ -68,8 +66,7 @@ def test_retrieve(fake_synonym_set: SynonymSet) -> None: assert len(mock.request_history) == 1 assert mock.request_history[0].method == "GET" assert ( - mock.request_history[0].url - == "http://nearest:8108/synonym_sets/test-set" + mock.request_history[0].url == "http://nearest:8108/synonym_sets/test-set" ) assert response == json_response @@ -90,8 +87,7 @@ def test_delete(fake_synonym_set: SynonymSet) -> None: assert len(mock.request_history) == 1 assert mock.request_history[0].method == "DELETE" assert ( - mock.request_history[0].url - == "http://nearest:8108/synonym_sets/test-set" + mock.request_history[0].url == "http://nearest:8108/synonym_sets/test-set" ) assert response == json_response @@ -112,7 +108,7 @@ def test_actual_retrieve( "root": "", "synonyms": ["companies", "corporations", "firms"], } - ] + ], } @@ -124,5 +120,3 @@ def test_actual_delete( response = actual_synonym_sets["test-set"].delete() assert response == {"name": "test-set"} - - diff --git a/tests/synonym_sets_test.py b/tests/synonym_sets_test.py index 24cea59..fd0e532 100644 --- a/tests/synonym_sets_test.py +++ b/tests/synonym_sets_test.py @@ -25,9 +25,7 @@ Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), @@ -109,9 +107,7 @@ def test_create(fake_synonym_sets: SynonymSets) -> None: assert mock.call_count == 1 assert mock.called is True assert mock.last_request.method == "PUT" - assert ( - mock.last_request.url == "http://nearest:8108/synonym_sets/test-set" - ) + assert mock.last_request.url == "http://nearest:8108/synonym_sets/test-set" assert mock.last_request.json() == payload @@ -159,5 +155,3 @@ def test_actual_retrieve( "name": "test-set", }, ) - - diff --git a/tests/synonym_test.py b/tests/synonym_test.py index d25d937..0b2922c 100644 --- a/tests/synonym_test.py +++ b/tests/synonym_test.py @@ -23,9 +23,7 @@ Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), diff --git a/tests/synonyms_test.py b/tests/synonyms_test.py index 81ae716..22f8a0c 100644 --- a/tests/synonyms_test.py +++ b/tests/synonyms_test.py @@ -22,9 +22,7 @@ Client( { "api_key": "xyz", - "nodes": [ - {"host": "localhost", "port": 8108, "protocol": "http"} - ], + "nodes": [{"host": "localhost", "port": 8108, "protocol": "http"}], } ) ), diff --git a/tests/utils/version.py b/tests/utils/version.py index a7d375c..33b9151 100644 --- a/tests/utils/version.py +++ b/tests/utils/version.py @@ -21,5 +21,3 @@ def is_v30_or_above(client: Client) -> bool: return False except Exception: return False - - From 60fda9587a313ae05c5d010997a2207aa7c505d0 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:47:41 +0200 Subject: [PATCH 17/33] fix(test): create the companies collection before creating the rule --- tests/analytics_events_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/analytics_events_test.py b/tests/analytics_events_test.py index 34243ba..b970e2c 100644 --- a/tests/analytics_events_test.py +++ b/tests/analytics_events_test.py @@ -70,6 +70,15 @@ def test_status(actual_client: Client, delete_all: None) -> None: def test_retrieve_events( actual_client: Client, delete_all: None, delete_all_analytics_rules: None ) -> None: + actual_client.collections.create( + { + "name": "companies", + "fields": [ + {"name": "user_id", "type": "string"}, + ], + } + ) + actual_client.analytics.rules.create( { "name": "company_analytics_rule", From 29291eb258155ab5c5cbf766dd6a0d67ca35979d Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 29 Oct 2025 13:49:46 +0200 Subject: [PATCH 18/33] chore(config): use `logger.warning` instead of deprecated `warn` function --- src/typesense/configuration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/typesense/configuration.py b/src/typesense/configuration.py index d59ac5e..f21b8cb 100644 --- a/src/typesense/configuration.py +++ b/src/typesense/configuration.py @@ -371,7 +371,7 @@ def show_deprecation_warnings(config_dict: ConfigDict) -> None: to check for deprecated fields. """ if config_dict.get("timeout_seconds"): - logger.warn( + logger.warning( " ".join( [ "Deprecation warning: timeout_seconds is now renamed", @@ -381,7 +381,7 @@ def show_deprecation_warnings(config_dict: ConfigDict) -> None: ) if config_dict.get("master_node"): - logger.warn( + logger.warning( " ".join( [ "Deprecation warning: master_node is now consolidated", @@ -391,7 +391,7 @@ def show_deprecation_warnings(config_dict: ConfigDict) -> None: ) if config_dict.get("read_replica_nodes"): - logger.warn( + logger.warning( " ".join( [ "Deprecation warning: read_replica_nodes is now", From 4e851d8a9e1a4da9397b66779682eaa390ed3576 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 13:03:48 +0200 Subject: [PATCH 19/33] feat(curation): register curation sets to main client object --- src/typesense/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/typesense/client.py b/src/typesense/client.py index 92354b2..88ba60e 100644 --- a/src/typesense/client.py +++ b/src/typesense/client.py @@ -43,6 +43,7 @@ from typesense.collections import Collections from typesense.configuration import ConfigDict, Configuration from typesense.conversations_models import ConversationsModels +from typesense.curation_sets import CurationSets from typesense.debug import Debug from typesense.keys import Keys from typesense.metrics import Metrics @@ -74,6 +75,7 @@ class Client: aliases (Aliases): Instance for managing collection aliases. analyticsV1 (AnalyticsV1): Instance for analytics operations (V1). analytics (AnalyticsV30): Instance for analytics operations (v30). + curation_sets (CurationSets): Instance for Curation Sets (v30+) stemming (Stemming): Instance for stemming dictionary operations. operations (Operations): Instance for various Typesense operations. debug (Debug): Instance for debug operations. @@ -107,6 +109,7 @@ def __init__(self, config_dict: ConfigDict) -> None: self.analyticsV1 = AnalyticsV1(self.api_call) self.analytics = Analytics(self.api_call) self.stemming = Stemming(self.api_call) + self.curation_sets = CurationSets(self.api_call) self.operations = Operations(self.api_call) self.debug = Debug(self.api_call) self.stopwords = Stopwords(self.api_call) From ff8f76fc725058a58d50b8d129d3e98ca4e1f081 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 13:04:04 +0200 Subject: [PATCH 20/33] docs(client): update docs for client object --- src/typesense/client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/typesense/client.py b/src/typesense/client.py index 88ba60e..81f67ca 100644 --- a/src/typesense/client.py +++ b/src/typesense/client.py @@ -74,7 +74,7 @@ class Client: keys (Keys): Instance for managing API keys. aliases (Aliases): Instance for managing collection aliases. analyticsV1 (AnalyticsV1): Instance for analytics operations (V1). - analytics (AnalyticsV30): Instance for analytics operations (v30). + analytics (Analytics): Instance for analytics operations (v30). curation_sets (CurationSets): Instance for Curation Sets (v30+) stemming (Stemming): Instance for stemming dictionary operations. operations (Operations): Instance for various Typesense operations. @@ -95,8 +95,10 @@ def __init__(self, config_dict: ConfigDict) -> None: Example: >>> config = { ... "api_key": "your_api_key", - ... "nodes": [{"host": "localhost", "port": "8108", "protocol": "http"}], - ... "connection_timeout_seconds": 2 + ... "nodes": [ + ... {"host": "localhost", "port": "8108", "protocol": "http"} + ... ], + ... "connection_timeout_seconds": 2, ... } >>> client = Client(config) """ @@ -143,7 +145,6 @@ def typed_collection( >>> class Company(DocumentSchema): ... name: str ... num_employees: int - ... >>> client = Client(config) >>> companies_collection = client.typed_collection(model=Company) # This is equivalent to: From d55bcb7f9275578526c688aa7bb44014825451bf Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 15:13:50 +0200 Subject: [PATCH 21/33] chore: add typing-extensions to dependency list --- pyproject.toml | 2 +- uv.lock | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1f26b2c..56fb095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", ] -dependencies = ["requests"] +dependencies = ["requests", "typing-extensions"] dynamic = ["version"] [project.urls] diff --git a/uv.lock b/uv.lock index 0846166..376f24b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", @@ -440,6 +440,7 @@ name = "typesense" source = { virtual = "." } dependencies = [ { name = "requests" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -457,7 +458,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "requests" }] +requires-dist = [ + { name = "requests" }, + { name = "typing-extensions" }, +] [package.metadata.requires-dev] dev = [ From ffe325f9b7e8873c4747f52f85993ef78ed1a180 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 15:15:17 +0200 Subject: [PATCH 22/33] feat: add suppress_deprecation_warnings configuration option - add suppress_deprecation_warnings field to configdict typeddict - initialize suppress_deprecation_warnings in configuration class - default to false to maintain existing warning behavior --- src/typesense/configuration.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/typesense/configuration.py b/src/typesense/configuration.py index f21b8cb..d82408d 100644 --- a/src/typesense/configuration.py +++ b/src/typesense/configuration.py @@ -80,6 +80,8 @@ class ConfigDict(typing.TypedDict): dictionaries or URLs that represent the read replica nodes. connection_timeout_seconds (float): The connection timeout in seconds. + + suppress_deprecation_warnings (bool): Whether to suppress deprecation warnings. """ nodes: typing.List[typing.Union[str, NodeConfigDict]] @@ -96,6 +98,7 @@ class ConfigDict(typing.TypedDict): typing.List[typing.Union[str, NodeConfigDict]] ] # deprecated connection_timeout_seconds: typing.NotRequired[float] + suppress_deprecation_warnings: typing.NotRequired[bool] class Node: @@ -220,6 +223,7 @@ def __init__( ) self.verify = config_dict.get("verify", True) self.additional_headers = config_dict.get("additional_headers", {}) + self.suppress_deprecation_warnings = config_dict.get("suppress_deprecation_warnings", False) def _handle_nearest_node( self, From d89728c46b7ed53f137cde91536e0031c0dfb8d1 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 15:14:36 +0200 Subject: [PATCH 23/33] feat: add deprecation warning decorator system - add warn_deprecation decorator for method deprecation warnings - track shown warnings to prevent duplicate messages - support configurable suppression via suppress_deprecation_warnings - integrate with apicall configuration for warning control --- src/typesense/logger.py | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/typesense/logger.py b/src/typesense/logger.py index 1be7890..2834e28 100644 --- a/src/typesense/logger.py +++ b/src/typesense/logger.py @@ -1,6 +1,78 @@ """Logging configuration for the Typesense Python client.""" +import functools import logging +import sys + +if sys.version_info >= (3, 11): + import typing +else: + import typing_extensions as typing logger = logging.getLogger("typesense") logger.setLevel(logging.WARN) + +_deprecation_warnings: typing.Dict[str, bool] = {} + +if sys.version_info >= (3, 11): + from typing import ParamSpec, TypeVar +else: + from typing_extensions import ParamSpec, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + + +def warn_deprecation( + message: str, + *, + flag_name: typing.Union[str, None] = None, +) -> typing.Callable[[typing.Callable[P, R]], typing.Callable[P, R]]: + """ + Decorator to warn about deprecation when a method is called. + + This decorator will log a deprecation warning once per flag_name when the + decorated method is called. The warning is only shown once to avoid spam. + + Args: + message: The deprecation warning message to display. + flag_name: Optional name for the warning flag. If not provided, a default + name will be generated based on the function's module and name. + + Returns: + A decorator function that wraps the target method. + + Example: + >>> @warn_deprecation("This method is deprecated", flag_name="my_method") + ... def my_method(self): + ... return "result" + """ + + def decorator(func: typing.Callable[P, R]) -> typing.Callable[P, R]: + if flag_name is None: + flag = f"{func.__module__}.{func.__qualname__}" + else: + flag = flag_name + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + suppress_warnings = False + if ( + args + and len(args) > 1 + and args[1] + and args[1].__class__.__name__ == "ApiCall" + and hasattr(args[1], "config") + ): + suppress_warnings = getattr( + args[1].config, "suppress_deprecation_warnings", False + ) + + if not suppress_warnings and not _deprecation_warnings.get(flag, False): + logger.warning(f"Deprecation warning: {message}") + _deprecation_warnings[flag] = True + return func(*args, **kwargs) + + return typing.cast(typing.Callable[P, R], wrapper) + + return decorator From e45871d26b1f7207667e1562e54b6feeed2dfd66 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 15:17:57 +0200 Subject: [PATCH 24/33] refactor: migrate deprecated apis to use warn_deprecation decorator - add warn_deprecation decorator to analytics_rule_v1 and analytics_rules_v1 - add warn_deprecation decorator to override and overrides classes - add warn_deprecation decorator to synonym and synonyms classes - remove manual deprecation warning code and global flags - replace manual logger.warning calls with decorator-based warnings --- src/typesense/analytics_rule_v1.py | 5 +++++ src/typesense/analytics_rules_v1.py | 6 ++++++ src/typesense/override.py | 6 ++++++ src/typesense/overrides.py | 5 +++++ src/typesense/synonym.py | 20 ++++++-------------- src/typesense/synonyms.py | 20 ++++++-------------- 6 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/typesense/analytics_rule_v1.py b/src/typesense/analytics_rule_v1.py index dc6890d..e3f8fc0 100644 --- a/src/typesense/analytics_rule_v1.py +++ b/src/typesense/analytics_rule_v1.py @@ -28,6 +28,7 @@ import typing_extensions as typing from typesense.api_call import ApiCall +from typesense.logger import warn_deprecation from typesense.types.analytics_rule_v1 import ( RuleDeleteSchema, RuleSchemaForCounters, @@ -47,6 +48,10 @@ class AnalyticsRuleV1: rule_id (str): The ID of the analytics rule. """ + @warn_deprecation( + "AnalyticsRuleV1 is deprecated on v30+. Use client.analytics.rules[rule_id] instead.", + flag_name="analytics_rules_v1_deprecation", + ) def __init__(self, api_call: ApiCall, rule_id: str): """ Initialize the AnalyticsRuleV1 object. diff --git a/src/typesense/analytics_rules_v1.py b/src/typesense/analytics_rules_v1.py index a850d37..c099726 100644 --- a/src/typesense/analytics_rules_v1.py +++ b/src/typesense/analytics_rules_v1.py @@ -27,6 +27,8 @@ import sys +from typesense.logger import warn_deprecation + if sys.version_info >= (3, 11): import typing else: @@ -63,6 +65,10 @@ class AnalyticsRulesV1(object): resource_path: typing.Final[str] = "/analytics/rules" + @warn_deprecation( + "AnalyticsRulesV1 is deprecated on v30+. Use client.analytics instead.", + flag_name="analytics_rules_v1_deprecation", + ) def __init__(self, api_call: ApiCall): """ Initialize the AnalyticsRulesV1 object. diff --git a/src/typesense/override.py b/src/typesense/override.py index 478a6d8..12a700d 100644 --- a/src/typesense/override.py +++ b/src/typesense/override.py @@ -22,6 +22,7 @@ """ from typesense.api_call import ApiCall +from typesense.logger import warn_deprecation from typesense.types.override import OverrideDeleteSchema, OverrideSchema @@ -38,6 +39,11 @@ class Override: override_id (str): The ID of the override. """ + @warn_deprecation( + "The override API (collections/{collection}/overrides/{override_id}) is deprecated is removed on v30+. " + "Use curation sets (curation_sets) instead.", + flag_name="overrides_deprecation", + ) def __init__( self, api_call: ApiCall, diff --git a/src/typesense/overrides.py b/src/typesense/overrides.py index 2674f42..d0d7941 100644 --- a/src/typesense/overrides.py +++ b/src/typesense/overrides.py @@ -30,6 +30,7 @@ import sys from typesense.api_call import ApiCall +from typesense.logger import warn_deprecation from typesense.override import Override from typesense.types.override import ( OverrideCreateSchema, @@ -59,6 +60,10 @@ class Overrides: resource_path: typing.Final[str] = "overrides" + @warn_deprecation( + "Overrides is deprecated on v30+. Use client.curation_sets instead.", + flag_name="overrides_deprecation", + ) def __init__( self, api_call: ApiCall, diff --git a/src/typesense/synonym.py b/src/typesense/synonym.py index 53f9bd3..4119620 100644 --- a/src/typesense/synonym.py +++ b/src/typesense/synonym.py @@ -22,11 +22,9 @@ """ from typesense.api_call import ApiCall -from typesense.logger import logger +from typesense.logger import warn_deprecation from typesense.types.synonym import SynonymDeleteSchema, SynonymSchema -_synonym_deprecation_warned = False - class Synonym: """ @@ -41,6 +39,11 @@ class Synonym: synonym_id (str): The ID of the synonym. """ + @warn_deprecation( + "The synonym API (collections/{collection}/synonyms/{synonym_id}) is deprecated is removed on v30+. " + "Use synonym sets (synonym_sets) instead.", + flag_name="synonyms_deprecation", + ) def __init__( self, api_call: ApiCall, @@ -66,7 +69,6 @@ def retrieve(self) -> SynonymSchema: Returns: SynonymSchema: The schema containing the synonym details. """ - self._maybe_warn_deprecation() return self.api_call.get(self._endpoint_path(), entity_type=SynonymSchema) def delete(self) -> SynonymDeleteSchema: @@ -76,7 +78,6 @@ def delete(self) -> SynonymDeleteSchema: Returns: SynonymDeleteSchema: The schema containing the deletion response. """ - self._maybe_warn_deprecation() return self.api_call.delete( self._endpoint_path(), entity_type=SynonymDeleteSchema, @@ -100,12 +101,3 @@ def _endpoint_path(self) -> str: self.synonym_id, ], ) - - def _maybe_warn_deprecation(self) -> None: - global _synonym_deprecation_warned - if not _synonym_deprecation_warned: - logger.warning( - "The synonyms API (collections/{collection}/synonyms) is deprecated and will be " - "removed in a future release. Use synonym sets (synonym_sets) instead." - ) - _synonym_deprecation_warned = True diff --git a/src/typesense/synonyms.py b/src/typesense/synonyms.py index c1bd6b7..6660984 100644 --- a/src/typesense/synonyms.py +++ b/src/typesense/synonyms.py @@ -28,15 +28,13 @@ import sys from typesense.api_call import ApiCall +from typesense.logger import warn_deprecation from typesense.synonym import Synonym from typesense.types.synonym import ( SynonymCreateSchema, SynonymSchema, SynonymsRetrieveSchema, ) -from typesense.logger import logger - -_synonyms_deprecation_warned = False if sys.version_info >= (3, 11): import typing @@ -60,6 +58,11 @@ class Synonyms: resource_path: typing.Final[str] = "synonyms" + @warn_deprecation( + "The synonyms API (collections/{collection}/synonyms) is deprecated is removed on v30+. " + "Use synonym sets (synonym_sets) instead.", + flag_name="synonyms_deprecation", + ) def __init__(self, api_call: ApiCall, collection_name: str): """ Initialize the Synonyms object. @@ -101,7 +104,6 @@ def upsert(self, synonym_id: str, schema: SynonymCreateSchema) -> SynonymSchema: Returns: SynonymSchema: The created or updated synonym. """ - self._maybe_warn_deprecation() response = self.api_call.put( self._endpoint_path(synonym_id), body=schema, @@ -116,7 +118,6 @@ def retrieve(self) -> SynonymsRetrieveSchema: Returns: SynonymsRetrieveSchema: The schema containing all synonyms. """ - self._maybe_warn_deprecation() response = self.api_call.get( self._endpoint_path(), entity_type=SynonymsRetrieveSchema, @@ -144,12 +145,3 @@ def _endpoint_path(self, synonym_id: typing.Union[str, None] = None) -> str: synonym_id, ], ) - - def _maybe_warn_deprecation(self) -> None: - global _synonyms_deprecation_warned - if not _synonyms_deprecation_warned: - logger.warning( - "The synonyms API (collections/{collection}/synonyms) is deprecated and will be " - "removed in a future release. Use synonym sets (synonym_sets) instead." - ) - _synonyms_deprecation_warned = True From d56213051340c94690570ad66d1226db4556dbd8 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 15:19:02 +0200 Subject: [PATCH 25/33] feat: add typing deprecation decorators to deprecated apis - add @deprecated decorator to analytics_rule_v1, analytics_v1 classes - add @deprecated decorator to override, overrides, synonyms classes - convert analyticsV1 to private attribute with deprecated property - convert overrides and synonyms to private attributes with deprecated properties - remove manual deprecation warning code from analytics_v1 - enable static type checker warnings for deprecated apis --- src/typesense/analytics_rule_v1.py | 9 ++++++--- src/typesense/analytics_rules_v1.py | 4 +--- src/typesense/analytics_v1.py | 13 +++---------- src/typesense/client.py | 14 ++++++++++++-- src/typesense/collection.py | 22 ++++++++++++++++++++-- src/typesense/override.py | 5 ++++- src/typesense/overrides.py | 7 +++++-- src/typesense/synonym.py | 2 +- src/typesense/synonyms.py | 7 +++++-- 9 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/typesense/analytics_rule_v1.py b/src/typesense/analytics_rule_v1.py index e3f8fc0..87a156d 100644 --- a/src/typesense/analytics_rule_v1.py +++ b/src/typesense/analytics_rule_v1.py @@ -27,6 +27,8 @@ else: import typing_extensions as typing +from typing_extensions import deprecated + from typesense.api_call import ApiCall from typesense.logger import warn_deprecation from typesense.types.analytics_rule_v1 import ( @@ -36,6 +38,9 @@ ) +@deprecated( + "AnalyticsRuleV1 is deprecated on v30+. Use client.analytics.rules[rule_id] instead." +) class AnalyticsRuleV1: """ Class for managing individual analytics rules in Typesense (V1). @@ -48,7 +53,7 @@ class AnalyticsRuleV1: rule_id (str): The ID of the analytics rule. """ - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "AnalyticsRuleV1 is deprecated on v30+. Use client.analytics.rules[rule_id] instead.", flag_name="analytics_rules_v1_deprecation", ) @@ -107,5 +112,3 @@ def _endpoint_path(self) -> str: from typesense.analytics_rules_v1 import AnalyticsRulesV1 return "/".join([AnalyticsRulesV1.resource_path, self.rule_id]) - - diff --git a/src/typesense/analytics_rules_v1.py b/src/typesense/analytics_rules_v1.py index c099726..2c93a98 100644 --- a/src/typesense/analytics_rules_v1.py +++ b/src/typesense/analytics_rules_v1.py @@ -65,7 +65,7 @@ class AnalyticsRulesV1(object): resource_path: typing.Final[str] = "/analytics/rules" - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "AnalyticsRulesV1 is deprecated on v30+. Use client.analytics instead.", flag_name="analytics_rules_v1_deprecation", ) @@ -167,5 +167,3 @@ def retrieve(self) -> RulesRetrieveSchema: entity_type=RulesRetrieveSchema, ) return response - - diff --git a/src/typesense/analytics_v1.py b/src/typesense/analytics_v1.py index cbacc4b..657af6c 100644 --- a/src/typesense/analytics_v1.py +++ b/src/typesense/analytics_v1.py @@ -17,13 +17,13 @@ versions through the use of the typing_extensions library. """ +from typing_extensions import deprecated + from typesense.analytics_rules_v1 import AnalyticsRulesV1 from typesense.api_call import ApiCall -from typesense.logger import logger - -_analytics_v1_deprecation_warned = False +@deprecated("AnalyticsV1 is deprecated on v30+. Use client.analytics instead.") class AnalyticsV1(object): """ Class for managing analytics in Typesense (V1). @@ -46,13 +46,6 @@ def __init__(self, api_call: ApiCall) -> None: @property def rules(self) -> AnalyticsRulesV1: - global _analytics_v1_deprecation_warned - if not _analytics_v1_deprecation_warned: - logger.warning( - "AnalyticsV1 is deprecated and will be removed in a future release. " - "Use client.analytics instead." - ) - _analytics_v1_deprecation_warned = True return self._rules diff --git a/src/typesense/client.py b/src/typesense/client.py index 81f67ca..19cae3a 100644 --- a/src/typesense/client.py +++ b/src/typesense/client.py @@ -28,6 +28,8 @@ import sys +from typing_extensions import deprecated + from typesense.types.document import DocumentSchema if sys.version_info >= (3, 11): @@ -36,8 +38,8 @@ import typing_extensions as typing from typesense.aliases import Aliases -from typesense.analytics_v1 import AnalyticsV1 from typesense.analytics import Analytics +from typesense.analytics_v1 import AnalyticsV1 from typesense.api_call import ApiCall from typesense.collection import Collection from typesense.collections import Collections @@ -108,7 +110,7 @@ def __init__(self, config_dict: ConfigDict) -> None: self.multi_search = MultiSearch(self.api_call) self.keys = Keys(self.api_call) self.aliases = Aliases(self.api_call) - self.analyticsV1 = AnalyticsV1(self.api_call) + self._analyticsV1 = AnalyticsV1(self.api_call) self.analytics = Analytics(self.api_call) self.stemming = Stemming(self.api_call) self.curation_sets = CurationSets(self.api_call) @@ -120,6 +122,14 @@ def __init__(self, config_dict: ConfigDict) -> None: self.conversations_models = ConversationsModels(self.api_call) self.nl_search_models = NLSearchModels(self.api_call) + @property + @deprecated( + "AnalyticsV1 is deprecated on v30+. Use client.analytics instead.", + category=None, + ) + def analyticsV1(self) -> AnalyticsV1: + return self._analyticsV1 + def typed_collection( self, *, diff --git a/src/typesense/collection.py b/src/typesense/collection.py index f648ebf..a898656 100644 --- a/src/typesense/collection.py +++ b/src/typesense/collection.py @@ -20,6 +20,8 @@ import sys +from typing_extensions import deprecated + from typesense.types.collection import CollectionSchema, CollectionUpdateSchema if sys.version_info >= (3, 11): @@ -63,8 +65,24 @@ def __init__(self, api_call: ApiCall, name: str): self.name = name self.api_call = api_call self.documents: Documents[TDoc] = Documents(api_call, name) - self.overrides = Overrides(api_call, name) - self.synonyms = Synonyms(api_call, name) + self._overrides = Overrides(api_call, name) + self._synonyms = Synonyms(api_call, name) + + @property + @deprecated( + "Synonyms is deprecated on v30+. Use client.synonym_sets instead.", + category=None, + ) + def synonyms(self) -> Synonyms: + return self._synonyms + + @property + @deprecated( + "Overrides is deprecated on v30+. Use client.curation_sets instead.", + category=None, + ) + def overrides(self) -> Overrides: + return self._overrides def retrieve(self) -> CollectionSchema: """ diff --git a/src/typesense/override.py b/src/typesense/override.py index 12a700d..a9613b0 100644 --- a/src/typesense/override.py +++ b/src/typesense/override.py @@ -21,11 +21,14 @@ versions through the use of the typing_extensions library. """ +from typing_extensions import deprecated + from typesense.api_call import ApiCall from typesense.logger import warn_deprecation from typesense.types.override import OverrideDeleteSchema, OverrideSchema +@deprecated("Override is deprecated on v30+. Use client.curation_sets instead.") class Override: """ Class for managing individual overrides in a Typesense collection. @@ -39,7 +42,7 @@ class Override: override_id (str): The ID of the override. """ - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "The override API (collections/{collection}/overrides/{override_id}) is deprecated is removed on v30+. " "Use curation sets (curation_sets) instead.", flag_name="overrides_deprecation", diff --git a/src/typesense/overrides.py b/src/typesense/overrides.py index d0d7941..4f8bc80 100644 --- a/src/typesense/overrides.py +++ b/src/typesense/overrides.py @@ -29,6 +29,8 @@ import sys +from typing_extensions import deprecated + from typesense.api_call import ApiCall from typesense.logger import warn_deprecation from typesense.override import Override @@ -44,6 +46,7 @@ import typing_extensions as typing +@deprecated("Overrides is deprecated on v30+. Use client.curation_sets instead.") class Overrides: """ Class for managing overrides in a Typesense collection. @@ -60,7 +63,7 @@ class Overrides: resource_path: typing.Final[str] = "overrides" - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "Overrides is deprecated on v30+. Use client.curation_sets instead.", flag_name="overrides_deprecation", ) @@ -68,7 +71,7 @@ def __init__( self, api_call: ApiCall, collection_name: str, - ) -> None: + ) -> None: """ Initialize the Overrides object. diff --git a/src/typesense/synonym.py b/src/typesense/synonym.py index 4119620..6bea97d 100644 --- a/src/typesense/synonym.py +++ b/src/typesense/synonym.py @@ -39,7 +39,7 @@ class Synonym: synonym_id (str): The ID of the synonym. """ - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "The synonym API (collections/{collection}/synonyms/{synonym_id}) is deprecated is removed on v30+. " "Use synonym sets (synonym_sets) instead.", flag_name="synonyms_deprecation", diff --git a/src/typesense/synonyms.py b/src/typesense/synonyms.py index 6660984..3a5622f 100644 --- a/src/typesense/synonyms.py +++ b/src/typesense/synonyms.py @@ -27,6 +27,8 @@ import sys +from typing_extensions import deprecated + from typesense.api_call import ApiCall from typesense.logger import warn_deprecation from typesense.synonym import Synonym @@ -42,6 +44,7 @@ import typing_extensions as typing +@deprecated("Synonyms is deprecated on v30+. Use client.synonym_sets instead.") class Synonyms: """ Class for managing synonyms in a Typesense collection. @@ -58,12 +61,12 @@ class Synonyms: resource_path: typing.Final[str] = "synonyms" - @warn_deprecation( + @warn_deprecation( # type: ignore[misc] "The synonyms API (collections/{collection}/synonyms) is deprecated is removed on v30+. " "Use synonym sets (synonym_sets) instead.", flag_name="synonyms_deprecation", ) - def __init__(self, api_call: ApiCall, collection_name: str): + def __init__(self, api_call: ApiCall, collection_name: str) -> None: """ Initialize the Synonyms object. From 2792b34e056d130c275380a42947beaf940b0a43 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 16:12:13 +0200 Subject: [PATCH 26/33] fix(types): fix update collection type to include curation & synonyms --- src/typesense/types/collection.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/typesense/types/collection.py b/src/typesense/types/collection.py index 1ce839c..702fb41 100644 --- a/src/typesense/types/collection.py +++ b/src/typesense/types/collection.py @@ -225,10 +225,15 @@ class CollectionUpdateSchema(typing.TypedDict): """ - fields: typing.List[ - typing.Union[ - RegularCollectionFieldSchema, - ReferenceCollectionFieldSchema, - DropCollectionFieldSchema, + fields: typing.NotRequired[ + typing.List[ + typing.Union[ + RegularCollectionFieldSchema, + ReferenceCollectionFieldSchema, + DropCollectionFieldSchema, + ] ] ] + synonym_sets: typing.NotRequired[typing.List[str]] + curation_sets: typing.NotRequired[typing.List[str]] + From eaec38677076056df763cf0629d38225fc167165 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 16:12:27 +0200 Subject: [PATCH 27/33] fix(curation): fix method signature of upserting curation sets --- src/typesense/curation_set.py | 20 +++++++++++++++----- src/typesense/curation_sets.py | 14 -------------- tests/curation_sets_test.py | 5 ++--- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/typesense/curation_set.py b/src/typesense/curation_set.py index 3828161..7cf53f5 100644 --- a/src/typesense/curation_set.py +++ b/src/typesense/curation_set.py @@ -9,11 +9,12 @@ from typesense.api_call import ApiCall from typesense.types.curation_set import ( - CurationSetSchema, + CurationItemDeleteSchema, + CurationItemSchema, CurationSetDeleteSchema, CurationSetListItemResponseSchema, - CurationItemSchema, - CurationItemDeleteSchema, + CurationSetSchema, + CurationSetUpsertSchema, ) @@ -43,6 +44,17 @@ def delete(self) -> CurationSetDeleteSchema: ) return response + def upsert( + self, + payload: CurationSetUpsertSchema, + ) -> CurationSetSchema: + response: CurationSetSchema = self.api_call.put( + "/".join([self._endpoint_path]), + body=payload, + entity_type=CurationSetSchema, + ) + return response + # Items sub-resource @property def _items_path(self) -> str: @@ -92,5 +104,3 @@ def delete_item(self, item_id: str) -> CurationItemDeleteSchema: entity_type=CurationItemDeleteSchema, ) return response - - diff --git a/src/typesense/curation_sets.py b/src/typesense/curation_sets.py index b13303e..4a30abc 100644 --- a/src/typesense/curation_sets.py +++ b/src/typesense/curation_sets.py @@ -10,9 +10,7 @@ from typesense.api_call import ApiCall from typesense.curation_set import CurationSet from typesense.types.curation_set import ( - CurationSetSchema, CurationSetsListResponseSchema, - CurationSetUpsertSchema, ) @@ -34,15 +32,3 @@ def __getitem__(self, curation_set_name: str) -> CurationSet: from typesense.curation_set import CurationSet as PerSet return PerSet(self.api_call, curation_set_name) - - def upsert( - self, - curation_set_name: str, - payload: CurationSetUpsertSchema, - ) -> CurationSetSchema: - response: CurationSetSchema = self.api_call.put( - "/".join([CurationSets.resource_path, curation_set_name]), - body=payload, - entity_type=CurationSetSchema, - ) - return response diff --git a/tests/curation_sets_test.py b/tests/curation_sets_test.py index 82091d5..88c70bf 100644 --- a/tests/curation_sets_test.py +++ b/tests/curation_sets_test.py @@ -96,7 +96,7 @@ def test_upsert(fake_curation_sets: CurationSets) -> None: } ] } - response = fake_curation_sets.upsert("products", payload) + response = fake_curation_sets["products"].upsert(payload) assert response == json_response assert mock.call_count == 1 @@ -111,8 +111,7 @@ def test_actual_upsert( delete_all_curation_sets: None, ) -> None: """Test that the CurationSets object can upsert a curation set on Typesense Server.""" - response = actual_curation_sets.upsert( - "products", + response = actual_curation_sets["products"].upsert( { "items": [ { From eb147fc688c59b7f68d813d1bf8855e56b4b9d8c Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 14 Nov 2025 16:12:42 +0200 Subject: [PATCH 28/33] fix(synonyms): fix method signature of upserting synonym sets --- src/typesense/synonym_set.py | 21 +++++++++++++-------- src/typesense/synonym_sets.py | 13 ------------- tests/synonym_sets_test.py | 6 ++---- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/typesense/synonym_set.py b/src/typesense/synonym_set.py index 0828791..e9eaae3 100644 --- a/src/typesense/synonym_set.py +++ b/src/typesense/synonym_set.py @@ -9,10 +9,11 @@ from typesense.api_call import ApiCall from typesense.types.synonym_set import ( + SynonymItemDeleteSchema, + SynonymItemSchema, + SynonymSetCreateSchema, SynonymSetDeleteSchema, SynonymSetRetrieveSchema, - SynonymItemSchema, - SynonymItemDeleteSchema, ) @@ -35,13 +36,21 @@ def retrieve(self) -> SynonymSetRetrieveSchema: ) return response + def upsert(self, set: SynonymSetCreateSchema) -> SynonymSetCreateSchema: + response: SynonymSetCreateSchema = self.api_call.put( + self._endpoint_path, + entity_type=SynonymSetCreateSchema, + body=set, + ) + return response + def delete(self) -> SynonymSetDeleteSchema: response: SynonymSetDeleteSchema = self.api_call.delete( self._endpoint_path, entity_type=SynonymSetDeleteSchema, ) return response - + @property def _items_path(self) -> str: return "/".join([self._endpoint_path, "items"]) # /synonym_sets/{name}/items @@ -57,9 +66,7 @@ def list_items( "offset": offset, } clean_params: typing.Dict[str, int] = { - k: v - for k, v in params.items() - if v is not None + k: v for k, v in params.items() if v is not None } response: typing.List[SynonymItemSchema] = self.api_call.get( self._items_path, @@ -91,5 +98,3 @@ def delete_item(self, item_id: str) -> SynonymItemDeleteSchema: "/".join([self._items_path, item_id]), entity_type=SynonymItemDeleteSchema ) return response - - diff --git a/src/typesense/synonym_sets.py b/src/typesense/synonym_sets.py index 543e77c..ee4587f 100644 --- a/src/typesense/synonym_sets.py +++ b/src/typesense/synonym_sets.py @@ -10,7 +10,6 @@ from typesense.api_call import ApiCall from typesense.synonym_set import SynonymSet from typesense.types.synonym_set import ( - SynonymSetCreateSchema, SynonymSetSchema, ) @@ -33,15 +32,3 @@ def __getitem__(self, synonym_set_name: str) -> SynonymSet: from typesense.synonym_set import SynonymSet as PerSet return PerSet(self.api_call, synonym_set_name) - - def upsert( - self, - synonym_set_name: str, - payload: SynonymSetCreateSchema, - ) -> SynonymSetSchema: - response: SynonymSetSchema = self.api_call.put( - "/".join([SynonymSets.resource_path, synonym_set_name]), - body=payload, - entity_type=SynonymSetSchema, - ) - return response diff --git a/tests/synonym_sets_test.py b/tests/synonym_sets_test.py index fd0e532..f63c196 100644 --- a/tests/synonym_sets_test.py +++ b/tests/synonym_sets_test.py @@ -19,7 +19,6 @@ SynonymSetSchema, ) - pytestmark = pytest.mark.skipif( not is_v30_or_above( Client( @@ -102,7 +101,7 @@ def test_create(fake_synonym_sets: SynonymSets) -> None: } ] } - fake_synonym_sets.upsert("test-set", payload) + fake_synonym_sets["test-set"].upsert(payload) assert mock.call_count == 1 assert mock.called is True @@ -116,8 +115,7 @@ def test_actual_create( delete_all_synonym_sets: None, ) -> None: """Test that the SynonymSets object can create a synonym set on Typesense Server.""" - response = actual_synonym_sets.upsert( - "test-set", + response = actual_synonym_sets["test-set"].upsert( { "items": [ { From bd8a43dfe5aff297af33672da60998785e2e6e00 Mon Sep 17 00:00:00 2001 From: Kishore Nallan Date: Fri, 14 Nov 2025 20:27:38 +0400 Subject: [PATCH 29/33] Bump version --- src/typesense/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typesense/__init__.py b/src/typesense/__init__.py index 5f7f548..147e6a8 100644 --- a/src/typesense/__init__.py +++ b/src/typesense/__init__.py @@ -1,4 +1,4 @@ from .client import Client # NOQA -__version__ = "1.2.0" +__version__ = "1.3.0" From 737f571e8c476e0942418ca53c72658c7ca9d323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 23 Nov 2025 22:36:21 +0300 Subject: [PATCH 30/33] Specify supported Python versions via trove classifiers --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 56fb095..59537c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,10 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = ["requests", "typing-extensions"] dynamic = ["version"] From 9a1d54124f13b955215441c33a53cf8167d89798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Mon, 24 Nov 2025 18:47:00 +0300 Subject: [PATCH 31/33] Enable ruff formatter in CI --- .github/workflows/test-and-lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 9552400..75b6b9c 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -47,6 +47,7 @@ jobs: - name: Lint with Ruff run: | uv run ruff check src/typesense + uv run ruff format src/typesense - name: Check types with mypy run: | From e77447891903f0a6e8841e62a5efba434b628128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Mon, 24 Nov 2025 18:47:45 +0300 Subject: [PATCH 32/33] Apply "ruff format" fixes --- README.md | 3 --- src/typesense/analytics_events.py | 2 -- src/typesense/analytics_v1.py | 2 -- src/typesense/collections.py | 13 ++++++------- src/typesense/configuration.py | 4 +++- src/typesense/overrides.py | 4 ++-- src/typesense/synonyms.py | 4 ++-- src/typesense/types/analytics_rule_v1.py | 2 -- src/typesense/types/collection.py | 1 - src/typesense/types/curation_set.py | 2 -- src/typesense/types/synonym_set.py | 4 +++- 11 files changed, 16 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bbe9f08..208a7a5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,3 @@ Bug reports and pull requests are welcome on GitHub at [https://github.com/types ## License `typesense-python` is distributed under the Apache 2 license. - - - diff --git a/src/typesense/analytics_events.py b/src/typesense/analytics_events.py index c462e6c..651591d 100644 --- a/src/typesense/analytics_events.py +++ b/src/typesense/analytics_events.py @@ -69,5 +69,3 @@ def status(self) -> AnalyticsStatus: entity_type=AnalyticsStatus, ) return response - - diff --git a/src/typesense/analytics_v1.py b/src/typesense/analytics_v1.py index 657af6c..baa18a7 100644 --- a/src/typesense/analytics_v1.py +++ b/src/typesense/analytics_v1.py @@ -47,5 +47,3 @@ def __init__(self, api_call: ApiCall) -> None: @property def rules(self) -> AnalyticsRulesV1: return self._rules - - diff --git a/src/typesense/collections.py b/src/typesense/collections.py index 72fa381..dd9fe53 100644 --- a/src/typesense/collections.py +++ b/src/typesense/collections.py @@ -72,7 +72,6 @@ def __contains__(self, collection_name: str) -> bool: """ if collection_name in self.collections: try: # noqa: WPS229, WPS529 - self.collections[collection_name].retrieve() # noqa: WPS529 return True except Exception: @@ -100,7 +99,7 @@ def __getitem__(self, collection_name: str) -> Collection[TDoc]: Example: >>> collections = Collections(api_call) - >>> fruits_collection = collections['fruits'] + >>> fruits_collection = collections["fruits"] """ if not self.collections.get(collection_name): self.collections[collection_name] = Collection( @@ -126,11 +125,11 @@ def create(self, schema: CollectionCreateSchema) -> CollectionSchema: >>> schema = { ... "name": "companies", ... "fields": [ - ... {"name": "company_name", "type": "string" }, - ... {"name": "num_employees", "type": "int32" }, - ... {"name": "country", "type": "string", "facet": True } + ... {"name": "company_name", "type": "string"}, + ... {"name": "num_employees", "type": "int32"}, + ... {"name": "country", "type": "string", "facet": True}, ... ], - ... "default_sorting_field": "num_employees" + ... "default_sorting_field": "num_employees", ... } >>> created_schema = collections.create(schema) """ @@ -154,7 +153,7 @@ def retrieve(self) -> typing.List[CollectionSchema]: >>> collections = Collections(api_call) >>> all_collections = collections.retrieve() >>> for collection in all_collections: - ... print(collection['name']) + ... print(collection["name"]) """ call: typing.List[CollectionSchema] = self.api_call.get( endpoint=Collections.resource_path, diff --git a/src/typesense/configuration.py b/src/typesense/configuration.py index d82408d..1720233 100644 --- a/src/typesense/configuration.py +++ b/src/typesense/configuration.py @@ -223,7 +223,9 @@ def __init__( ) self.verify = config_dict.get("verify", True) self.additional_headers = config_dict.get("additional_headers", {}) - self.suppress_deprecation_warnings = config_dict.get("suppress_deprecation_warnings", False) + self.suppress_deprecation_warnings = config_dict.get( + "suppress_deprecation_warnings", False + ) def _handle_nearest_node( self, diff --git a/src/typesense/overrides.py b/src/typesense/overrides.py index 4f8bc80..8581e93 100644 --- a/src/typesense/overrides.py +++ b/src/typesense/overrides.py @@ -63,7 +63,7 @@ class Overrides: resource_path: typing.Final[str] = "overrides" - @warn_deprecation( # type: ignore[misc] + @warn_deprecation( # type: ignore[misc] "Overrides is deprecated on v30+. Use client.curation_sets instead.", flag_name="overrides_deprecation", ) @@ -71,7 +71,7 @@ def __init__( self, api_call: ApiCall, collection_name: str, - ) -> None: + ) -> None: """ Initialize the Overrides object. diff --git a/src/typesense/synonyms.py b/src/typesense/synonyms.py index 3a5622f..fe5f508 100644 --- a/src/typesense/synonyms.py +++ b/src/typesense/synonyms.py @@ -61,12 +61,12 @@ class Synonyms: resource_path: typing.Final[str] = "synonyms" - @warn_deprecation( # type: ignore[misc] + @warn_deprecation( # type: ignore[misc] "The synonyms API (collections/{collection}/synonyms) is deprecated is removed on v30+. " "Use synonym sets (synonym_sets) instead.", flag_name="synonyms_deprecation", ) - def __init__(self, api_call: ApiCall, collection_name: str) -> None: + def __init__(self, api_call: ApiCall, collection_name: str) -> None: """ Initialize the Synonyms object. diff --git a/src/typesense/types/analytics_rule_v1.py b/src/typesense/types/analytics_rule_v1.py index 3f76046..88ffd00 100644 --- a/src/typesense/types/analytics_rule_v1.py +++ b/src/typesense/types/analytics_rule_v1.py @@ -201,5 +201,3 @@ class RulesRetrieveSchema(typing.TypedDict): """ rules: typing.List[typing.Union[RuleSchemaForQueries, RuleSchemaForCounters]] - - diff --git a/src/typesense/types/collection.py b/src/typesense/types/collection.py index 702fb41..e49fbc0 100644 --- a/src/typesense/types/collection.py +++ b/src/typesense/types/collection.py @@ -236,4 +236,3 @@ class CollectionUpdateSchema(typing.TypedDict): ] synonym_sets: typing.NotRequired[typing.List[str]] curation_sets: typing.NotRequired[typing.List[str]] - diff --git a/src/typesense/types/curation_set.py b/src/typesense/types/curation_set.py index a19ee0f..6468166 100644 --- a/src/typesense/types/curation_set.py +++ b/src/typesense/types/curation_set.py @@ -126,5 +126,3 @@ class CurationSetDeleteSchema(typing.TypedDict): """Response schema for deleting a curation set.""" name: str - - diff --git a/src/typesense/types/synonym_set.py b/src/typesense/types/synonym_set.py index 9d0dfe1..d036411 100644 --- a/src/typesense/types/synonym_set.py +++ b/src/typesense/types/synonym_set.py @@ -29,6 +29,7 @@ class SynonymItemSchema(typing.TypedDict): locale: typing.NotRequired[Locales] symbols_to_index: typing.NotRequired[typing.List[str]] + class SynonymItemDeleteSchema(typing.TypedDict): """ Schema for deleting a synonym item. @@ -36,6 +37,7 @@ class SynonymItemDeleteSchema(typing.TypedDict): id: str + class SynonymSetCreateSchema(typing.TypedDict): """ Schema for creating or updating a synonym set. @@ -73,4 +75,4 @@ class SynonymSetDeleteSchema(typing.TypedDict): name (str): Name of the deleted synonym set. """ - name: str \ No newline at end of file + name: str From 06253b9619070bd13661f6c0438e76eea3c131d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Wed, 26 Nov 2025 20:22:34 +0300 Subject: [PATCH 33/33] Add support for Python 3.13 --- .github/workflows/test-and-lint.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 75b6b9c..9369bb3 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: Start Typesense diff --git a/pyproject.toml b/pyproject.toml index 59537c5..575d6c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = ["requests", "typing-extensions"] dynamic = ["version"]