From fba5eb33021fedb70a05f3c0f3d3956b1941e4db Mon Sep 17 00:00:00 2001 From: Harisaran G Date: Tue, 26 Aug 2025 13:27:13 +0530 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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",