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/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..c4a09e2 100644 --- a/src/typesense/analytics.py +++ b/src/typesense/analytics.py @@ -1,42 +1,14 @@ -""" -This module provides functionality for managing analytics in Typesense. - -Classes: - - Analytics: Handles operations related to analytics, including access to analytics rules. - -Methods: - - __init__: Initializes the Analytics object. - -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. -""" +"""Client for Typesense Analytics module.""" +from typesense.analytics_events import AnalyticsEvents from typesense.analytics_rules import AnalyticsRules from typesense.api_call import ApiCall -class Analytics(object): - """ - Class for managing analytics in Typesense. - - This class provides access to analytics-related functionalities, - currently including operations on analytics rules. - - Attributes: - rules (AnalyticsRules): An instance of AnalyticsRules for managing analytics rules. - """ +class Analytics: + """Client for v30 Analytics endpoints.""" def __init__(self, api_call: ApiCall) -> None: - """ - Initialize the Analytics object. - - Args: - api_call (ApiCall): The API call object for making requests. - """ + self.api_call = api_call self.rules = AnalyticsRules(api_call) + self.events = AnalyticsEvents(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..86b516d 100644 --- a/src/typesense/analytics_rule.py +++ b/src/typesense/analytics_rule.py @@ -1,104 +1,31 @@ -""" -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. -""" - -import sys - -if sys.version_info >= (3, 11): - import typing -else: - import typing_extensions as typing +"""Per-rule client for Analytics rules operations.""" from typesense.api_call import ApiCall -from typesense.types.analytics_rule import ( - RuleDeleteSchema, - RuleSchemaForCounters, - RuleSchemaForQueries, -) +from typesense.types.analytics import AnalyticsRuleSchema 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) -> AnalyticsRuleSchema: + 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) -> AnalyticsRuleSchema: + 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..a95dc60 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 @@ -34,130 +9,54 @@ 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 ( + AnalyticsRuleCreate, + AnalyticsRuleSchema, + 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] - - 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. + self.rules: typing.Dict[str, AnalyticsRuleSchema] = {} - Args: - rule (Union[RuleCreateSchemaForCounters, RuleCreateSchemaForQueries]): - The rule schema. Use RuleCreateSchemaForCounters for counter rules - and RuleCreateSchemaForQueries for query rules. + def __getitem__(self, rule_name: str) -> AnalyticsRuleSchema: + if rule_name not in self.rules: + self.rules[rule_name] = AnalyticsRule(self.api_call, rule_name) + return self.rules[rule_name] - 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) -> AnalyticsRuleSchema: + response: AnalyticsRuleSchema = 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=AnalyticsRuleSchema, ) + 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[AnalyticsRuleSchema]: + params: typing.Dict[str, str] = {} + if rule_tag: + params["rule_tag"] = rule_tag + response: typing.List[AnalyticsRuleSchema] = self.api_call.get( AnalyticsRules.resource_path, + params=params if params else None, as_json=True, - entity_type=RulesRetrieveSchema, + entity_type=typing.List[AnalyticsRuleSchema], + ) + return response + + 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=AnalyticsRuleSchema, ) return response 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..cbacc4b --- /dev/null +++ b/src/typesense/analytics_v1.py @@ -0,0 +1,58 @@ +""" +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 +from typesense.logger import logger + +_analytics_v1_deprecation_warned = False + + +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) + + @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 f60acd0..92354b2 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 @@ -50,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) @@ -70,7 +72,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,11 +104,13 @@ 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) 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/curation_set.py b/src/typesense/curation_set.py new file mode 100644 index 0000000..3828161 --- /dev/null +++ b/src/typesense/curation_set.py @@ -0,0 +1,96 @@ +"""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, + 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 + } + 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..b13303e --- /dev/null +++ b/src/typesense/curation_sets.py @@ -0,0 +1,48 @@ +"""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.curation_set import CurationSet +from typesense.types.curation_set import ( + CurationSetSchema, + CurationSetsListResponseSchema, + CurationSetUpsertSchema, +) + + +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.py b/src/typesense/synonym.py index 096affc..53f9bd3 100644 --- a/src/typesense/synonym.py +++ b/src/typesense/synonym.py @@ -22,8 +22,11 @@ """ from typesense.api_call import ApiCall +from typesense.logger import logger from typesense.types.synonym import SynonymDeleteSchema, SynonymSchema +_synonym_deprecation_warned = False + class Synonym: """ @@ -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..0828791 --- /dev/null +++ b/src/typesense/synonym_set.py @@ -0,0 +1,95 @@ +"""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, + SynonymItemSchema, + SynonymItemDeleteSchema, +) + + +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 + + @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 + } + 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) -> 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=SynonymItemDeleteSchema + ) + return response + + diff --git a/src/typesense/synonym_sets.py b/src/typesense/synonym_sets.py new file mode 100644 index 0000000..543e77c --- /dev/null +++ b/src/typesense/synonym_sets.py @@ -0,0 +1,47 @@ +"""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.synonym_set import SynonymSet +from typesense.types.synonym_set import ( + SynonymSetCreateSchema, + 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/analytics.py b/src/typesense/types/analytics.py new file mode 100644 index 0000000..b442f7e --- /dev/null +++ b/src/typesense/types/analytics.py @@ -0,0 +1,81 @@ +"""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 + 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 + collection: str + timestamp: typing.NotRequired[int] + user_id: str + doc_id: typing.NotRequired[str] + doc_ids: typing.NotRequired[typing.List[str]] + query: typing.NotRequired[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: 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): + name: str + type: str + collection: str + event_type: str + params: typing.NotRequired[AnalyticsRuleParams] + rule_tag: typing.NotRequired[str] + + +class AnalyticsRuleUpdate(typing.TypedDict, total=False): + name: str + rule_tag: str + params: AnalyticsRuleParams + + +class AnalyticsRuleSchema(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..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] @@ -180,6 +181,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/src/typesense/types/curation_set.py b/src/typesense/types/curation_set.py new file mode 100644 index 0000000..a19ee0f --- /dev/null +++ b/src/typesense/types/curation_set.py @@ -0,0 +1,130 @@ +"""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 CurationRuleTagsSchema(typing.TypedDict): + """ + Schema for a curation rule using tags. + """ + + 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 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). + """ + + id: str + rule: CurationRuleSchema + 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] + effective_from_ts: typing.NotRequired[int] + effective_to_ts: typing.NotRequired[int] + metadata: typing.NotRequired[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, total=False): + """ + Response schema for a curation set. + """ + + name: typing.NotRequired[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 new file mode 100644 index 0000000..9d0dfe1 --- /dev/null +++ b/src/typesense/types/synonym_set.py @@ -0,0 +1,76 @@ +"""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 SynonymItemDeleteSchema(typing.TypedDict): + """ + Schema for deleting a synonym item. + """ + + id: 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 \ No newline at end of file diff --git a/tests/analytics_events_test.py b/tests/analytics_events_test.py new file mode 100644 index 0000000..b970e2c --- /dev/null +++ b/tests/analytics_events_test.py @@ -0,0 +1,149 @@ +"""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 +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.collections.create( + { + "name": "companies", + "fields": [ + {"name": "user_id", "type": "string"}, + ], + } + ) + + 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_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..199e7ae 100644 --- a/tests/analytics_rule_test.py +++ b/tests/analytics_rule_test.py @@ -1,120 +1,68 @@ -"""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() - - expected: RuleDeleteSchema = { - "name": "company_analytics_rule", - } - assert response == expected + 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 new file mode 100644 index 0000000..d30b002 --- /dev/null +++ b/tests/analytics_rule_v1_test.py @@ -0,0 +1,135 @@ +"""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 + +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 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" + ) + + +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 + + +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 + + +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 + + +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..70f16f5 100644 --- a/tests/analytics_rules_test.py +++ b/tests/analytics_rules_test.py @@ -1,141 +1,90 @@ -"""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, -) - - -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 +from typesense.analytics_rule import AnalyticsRule +from typesense.types.analytics import AnalyticsRuleCreate -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" - ) +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_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": "search", + "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 +94,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": "search", + "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 +112,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 +130,6 @@ 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..7eb2749 --- /dev/null +++ b/tests/analytics_rules_v1_test.py @@ -0,0 +1,238 @@ +"""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, +) + + +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) + + 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: 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" + ) + + +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 + + +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 + + +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", + } + + +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"}, + }, + } + + +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"}, + }, + } + + +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..2ff12b6 100644 --- a/tests/analytics_test.py +++ b/tests/analytics_test.py @@ -1,12 +1,26 @@ -"""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( + 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 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..f617b7b --- /dev/null +++ b/tests/analytics_v1_test.py @@ -0,0 +1,36 @@ +"""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/api_call_test.py b/tests/api_call_test.py index 1d5fa11..96acadf 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): @@ -101,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 @@ -113,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" @@ -123,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/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..56c4429 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: @@ -198,6 +204,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, { @@ -211,6 +218,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, ], @@ -218,6 +226,8 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], } response.pop("created_at") @@ -237,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 84971bd..d742652 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: @@ -200,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, }, { @@ -213,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, }, ], @@ -220,6 +225,8 @@ def test_actual_create(actual_collections: Collections, delete_all: None) -> Non "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], } response = actual_collections.create( @@ -268,6 +275,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, { @@ -281,6 +289,7 @@ def test_actual_retrieve( "infix": False, "stem": False, "stem_dictionary": "", + "truncate_len": 100, "store": True, }, ], @@ -288,6 +297,8 @@ def test_actual_retrieve( "num_documents": 0, "symbols_to_index": [], "token_separators": [], + "synonym_sets": [], + "curation_sets": [], }, ] diff --git a/tests/curation_set_test.py b/tests/curation_set_test.py new file mode 100644 index 0000000..d8c4075 --- /dev/null +++ b/tests/curation_set_test.py @@ -0,0 +1,166 @@ +"""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.curation_sets import CurationSets +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 + + +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"} diff --git a/tests/curation_sets_test.py b/tests/curation_sets_test.py new file mode 100644 index 0000000..82091d5 --- /dev/null +++ b/tests/curation_sets_test.py @@ -0,0 +1,170 @@ +"""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, + 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.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 + + +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", + }, + ) diff --git a/tests/fixtures/analytics_rule_fixtures.py b/tests/fixtures/analytics_fixtures.py similarity index 76% rename from tests/fixtures/analytics_rule_fixtures.py rename to tests/fixtures/analytics_fixtures.py index 2f92008..9097294 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": "search", "params": { - "source": { - "collections": ["companies"], - }, - "destination": {"collection": "companies_queries"}, + "destination_collection": "companies_queries", + "limit": 1000, }, } @@ -52,19 +51,19 @@ 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") diff --git a/tests/fixtures/analytics_rule_v1_fixtures.py b/tests/fixtures/analytics_rule_v1_fixtures.py new file mode 100644 index 0000000..0dca1d0 --- /dev/null +++ b/tests/fixtures/analytics_rule_v1_fixtures.py @@ -0,0 +1,68 @@ +"""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/fixtures/curation_set_fixtures.py b/tests/fixtures/curation_set_fixtures.py new file mode 100644 index 0000000..3fc61b5 --- /dev/null +++ b/tests/fixtures/curation_set_fixtures.py @@ -0,0 +1,71 @@ +"""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/fixtures/synonym_set_fixtures.py b/tests/fixtures/synonym_set_fixtures.py new file mode 100644 index 0000000..41ad3bb --- /dev/null +++ b/tests/fixtures/synonym_set_fixtures.py @@ -0,0 +1,71 @@ +"""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 616ec11..9aec70e 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", @@ -20,13 +20,14 @@ "operations", "override", "stopword", + "synonym_set", "synonym", ] module_names = [ "aliases", - "analytics_rule", - "analytics_rules", + "analytics_rule_v1", + "analytics_rules_v1", "api_call", "client", "collection", @@ -41,6 +42,8 @@ "overrides", "operations", "synonyms", + "synonym_set", + "synonym_sets", "preprocess", "stopwords", ] 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/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: diff --git a/tests/override_test.py b/tests/override_test.py index 25b05fd..eba0dee 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,21 @@ 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..e543bea 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,6 +13,20 @@ 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: diff --git a/tests/synonym_set_items_test.py b/tests/synonym_set_items_test.py new file mode 100644 index 0000000..2cc1dc6 --- /dev/null +++ b/tests/synonym_set_items_test.py @@ -0,0 +1,81 @@ +"""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 diff --git a/tests/synonym_set_test.py b/tests/synonym_set_test.py new file mode 100644 index 0000000..b64aa5c --- /dev/null +++ b/tests/synonym_set_test.py @@ -0,0 +1,122 @@ +"""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.synonym_sets import SynonymSets +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, + 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, + 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..fd0e532 --- /dev/null +++ b/tests/synonym_sets_test.py @@ -0,0 +1,157 @@ +"""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", + }, + ) diff --git a/tests/synonym_test.py b/tests/synonym_test.py index 98caa08..0b2922c 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,27 @@ 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..22f8a0c 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,24 @@ ) 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..33b9151 --- /dev/null +++ b/tests/utils/version.py @@ -0,0 +1,23 @@ +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: + 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: + return False