diff --git a/benchmarks/response_time_becnhmark.py b/benchmarks/response_time_becnhmark.py index aa9df4a..2d07558 100644 --- a/benchmarks/response_time_becnhmark.py +++ b/benchmarks/response_time_becnhmark.py @@ -177,7 +177,7 @@ def run_benchmark(args): f" {args.dimension})" ) try: - client.create_namespace( + client.namespaces.create( namespace_name=args.namespace, type="vector", vector_dimension=args.dimension, @@ -213,7 +213,7 @@ def run_benchmark(args): f" ({len(batch_vectors)} vectors)..." ) try: - upload_response = client.upload_vectors( + upload_response = client.vectors.upload( namespace_name=args.namespace, vectors=payload["vectors"] ) if upload_response.get("status") == "success": @@ -254,7 +254,7 @@ def run_benchmark(args): for i, q_vec in enumerate(query_vectors): print(f" Running search query {i + 1}/{args.num_queries}...") try: - search_response = client.search( + search_response = client.similarity_search.query( namespaces=[args.namespace], query=q_vec, top_k=args.top_k ) exec_time = search_response.get("execution_time") @@ -343,7 +343,7 @@ def run_benchmark(args): with MoorchehClient( api_key=api_key, base_url=args.base_url ) as cleanup_client: - cleanup_client.delete_namespace(args.namespace) + cleanup_client.namespaces.delete(args.namespace) print("Namespace deleted successfully.") except Exception as e: print( diff --git a/examples/01_create_namespace.py b/examples/01_create_namespace.py index 7f3136c..eb55203 100644 --- a/examples/01_create_namespace.py +++ b/examples/01_create_namespace.py @@ -66,12 +66,12 @@ def main(): if vector_dimension: logger.info(f" Dimension: {vector_dimension}") - # 3. Call the create_namespace method + # 3. Call client.namespaces.create try: # Use the client's context manager for automatic cleanup with client: # SDK method call will produce its own logs (e.g., request details at DEBUG) - response = client.create_namespace( + response = client.namespaces.create( namespace_name=namespace_to_create, type=namespace_type, vector_dimension=vector_dimension, diff --git a/examples/02_list_namespaces.py b/examples/02_list_namespaces.py index 5e0b750..7d89d7f 100644 --- a/examples/02_list_namespaces.py +++ b/examples/02_list_namespaces.py @@ -47,13 +47,13 @@ def main(): ) # Log full traceback sys.exit(1) - # 2. Call the list_namespaces method + # 2. Call client.namespaces.list logger.info("Attempting to list namespaces...") try: # Use the client's context manager for automatic cleanup with client: # SDK method call will produce its own logs - response = client.list_namespaces() # Call the SDK method + response = client.namespaces.list() logger.info("--- API Response ---") # Use json.dumps for pretty printing the response dict in the log @@ -66,7 +66,7 @@ def main(): logger.info(f"Successfully retrieved {num_namespaces} namespace(s). ✅") # Optionally iterate and log names at DEBUG level if needed # for ns in response['namespaces']: - # logger.debug(f" - {ns.get('namespace_name')} (Type: {ns.get('type')}, Items: {ns.get('itemCount')})") # noqa: E501 + # logger.debug(f" - {ns.get('namespace_name')} (Type: {ns.get('type')}, Items: {ns.get('item_count')})") # noqa: E501 else: # Log a warning if the expected key is missing logger.warning( diff --git a/examples/03_upload_documents.py b/examples/03_upload_documents.py index ed0066f..745705e 100644 --- a/examples/03_upload_documents.py +++ b/examples/03_upload_documents.py @@ -51,6 +51,7 @@ def main(): # 2. Define Target Namespace and Documents to Upload target_namespace = "sdk-test-text-ns-01" # Use the text namespace created earlier + # Any keys other than id and text are treated as metadata (flat key/value). documents_to_upload = [ { "id": "sdk-doc-001", # Unique ID for this chunk @@ -80,12 +81,12 @@ def main(): f" '{target_namespace}'" ) - # 3. Call the upload_documents method + # 3. Call client.documents.upload try: # Use the client's context manager with client: # SDK method call will produce its own logs - response = client.upload_documents( + response = client.documents.upload( namespace_name=target_namespace, documents=documents_to_upload ) logger.info("--- API Response (Should be 202 Accepted) ---") diff --git a/examples/04_search_text.py b/examples/04_search_text.py index 42c2d1f..0408eda 100644 --- a/examples/04_search_text.py +++ b/examples/04_search_text.py @@ -62,12 +62,12 @@ def main(): if score_threshold is not None: logger.info(f" Threshold: {score_threshold}") - # 3. Call the search method + # 3. Call client.similarity_search.query try: # Use the client's context manager with client: # SDK method call will produce its own logs - response = client.search( + response = client.similarity_search.query( namespaces=[target_namespace], # Pass namespace(s) as a list query=search_query, # Pass the text query string top_k=top_k_results, diff --git a/examples/05_delete_document.py b/examples/05_delete_document.py index f8ebf75..cd99fc7 100644 --- a/examples/05_delete_document.py +++ b/examples/05_delete_document.py @@ -61,12 +61,12 @@ def main(): f" '{target_namespace}'" ) - # 3. Call the delete_documents method + # 3. Call client.documents.delete # Note: The method expects a LIST of IDs, even if deleting only one. try: with client: # SDK method call will produce its own logs - response = client.delete_documents( + response = client.documents.delete( namespace_name=target_namespace, ids=[document_id_to_delete], # Pass the ID inside a list ) @@ -76,8 +76,11 @@ def main(): logger.info("-----------------------------------------------------------") if response and response.get("status") == "success": - # Check if the specific ID is in the returned list (optional validation) - if document_id_to_delete in response.get("deleted_ids", []): + # API may return deleted_ids and/or requested_ids + ids_reported = ( + response.get("deleted_ids") or response.get("requested_ids") or [] + ) + if document_id_to_delete in ids_reported: logger.info( "Successfully processed deletion request for document ID" f" '{document_id_to_delete}'. ✅" diff --git a/examples/06_get_gen_ai_answer.py b/examples/06_get_gen_ai_answer.py index d3d9a2d..3d955cc 100644 --- a/examples/06_get_gen_ai_answer.py +++ b/examples/06_get_gen_ai_answer.py @@ -61,12 +61,12 @@ def main(): logger.info(f" Query: '{query}'") logger.info(f" Context (top_k): {top_k_context}") - # 3. Call the get_generative_answer method + # 3. Call client.answer.generate try: # Use the client's context manager with client: # SDK method call will produce its own logs - response = client.get_generative_answer( + response = client.answer.generate( namespace=target_namespace, query=query, top_k=top_k_context ) logger.info("--- API Response (Generative Answer) ---") diff --git a/examples/07_upload_file.py b/examples/07_upload_file.py index 644dca0..4417fb7 100644 --- a/examples/07_upload_file.py +++ b/examples/07_upload_file.py @@ -84,8 +84,8 @@ def main(): if response.get("success"): logger.info("✅ File uploaded successfully!") - logger.info(f" File: {response.get('fileName')}") - logger.info(f" Size: {response.get('fileSize')} bytes") + logger.info(f" File: {response.get('file_name')}") + logger.info(f" Size: {response.get('file_size')} bytes") logger.info(f" Namespace: {response.get('namespace')}") else: logger.warning( diff --git a/examples/08_delete_file.py b/examples/08_delete_file.py index 0558659..b15d18f 100644 --- a/examples/08_delete_file.py +++ b/examples/08_delete_file.py @@ -74,7 +74,7 @@ def main(): for result in response.get("results", []): logger.info( "File '%s' -> %s (%s)", - result.get("fileName"), + result.get("file_name"), result.get("status"), result.get("message"), ) diff --git a/examples/09_fetch_text_data.py b/examples/09_fetch_text_data.py new file mode 100644 index 0000000..48cc55c --- /dev/null +++ b/examples/09_fetch_text_data.py @@ -0,0 +1,93 @@ +# examples/09_fetch_text_data.py + +import json +import logging +import sys + +from moorcheh_sdk import ( + APIError, + AuthenticationError, + InvalidInputError, + MoorchehClient, + MoorchehError, + NamespaceNotFound, +) + +# --- Configure Logging --- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) +# ------------------------- + + +def main(): + """ + Example: list stored text and summary chunks for a text namespace (GET + fetch-text-data). Up to 100 items per response. For retrieving full + documents by ID, use client.documents.get instead. + """ + logger.info("--- Moorcheh SDK: Fetch Text Data Example ---") + + try: + client = MoorchehClient() + logger.info("Client initialized successfully.") + except AuthenticationError as e: + logger.error(f"Authentication Error: {e}") + logger.error( + "Please ensure the MOORCHEH_API_KEY environment variable is set correctly." + ) + sys.exit(1) + except MoorchehError as e: + logger.error(f"Error initializing client: {e}", exc_info=True) + sys.exit(1) + + target_namespace = "sdk-test-text-ns-01" + + logger.info(f"Target namespace (must be type=text): {target_namespace}") + + try: + with client: + logger.info(f"Fetching text chunks from namespace '{target_namespace}'...") + response = client.documents.fetch_text_data( + namespace_name=target_namespace, + ) + + logger.info("--- API Response (200 OK) ---") + logger.info(json.dumps(response, indent=2)) + logger.info("-------------------------------") + + if response.get("status") == "success": + items = response.get("items") or [] + stats = response.get("statistics") or {} + logger.info( + f"✅ Fetched {len(items)} item(s). " + f"statistics.total_items={stats.get('total_items')}" + ) + else: + logger.warning( + f"Unexpected status in response: {response.get('status')!r}" + ) + + except NamespaceNotFound: + logger.error(f"Namespace '{target_namespace}' was not found.") + logger.info( + "Create a text namespace first (see examples/01_create_namespace.py) " + "and upload data (examples/03_upload_documents.py)." + ) + except InvalidInputError as e: + logger.error(f"Invalid input: {e}") + except AuthenticationError as e: + logger.error(f"Authentication failed: {e}") + except APIError: + logger.exception("An API error occurred.") + except MoorchehError: + logger.exception("An SDK or network error occurred.") + except Exception: + logger.exception("An unexpected error occurred.") + + +if __name__ == "__main__": + main() diff --git a/examples/quickstart.py b/examples/quickstart.py index 5c4901a..9b53d73 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -60,7 +60,7 @@ def run_quickstart(): # --- 1. Create Namespaces --- try: logger.info(f"[Step 1a] Creating text namespace: '{text_ns_name}'") - creation_response_text = client.create_namespace( + creation_response_text = client.namespaces.create( namespace_name=text_ns_name, type="text" ) logger.info( @@ -79,7 +79,7 @@ def run_quickstart(): f"[Step 1b] Creating vector namespace: '{vector_ns_name}' (Dim:" f" {vector_dim})" ) - creation_response_vector = client.create_namespace( + creation_response_vector = client.namespaces.create( namespace_name=vector_ns_name, type="vector", vector_dimension=vector_dim, @@ -97,7 +97,7 @@ def run_quickstart(): # --- 2. List Namespaces --- logger.info("[Step 2] Listing namespaces...") try: - namespaces_response = client.list_namespaces() + namespaces_response = client.namespaces.list() logger.info("Current Namespaces:") logger.info( json.dumps(namespaces_response.get("namespaces", []), indent=2) @@ -107,6 +107,7 @@ def run_quickstart(): # --- 3. Upload Documents (to text namespace) --- logger.info(f"[Step 3] Uploading documents to '{text_ns_name}'...") + # Any keys other than id and text are treated as metadata (flat key/value). docs_to_upload = [ { "id": "qs-doc-1", @@ -128,13 +129,19 @@ def run_quickstart(): }, ] try: - upload_doc_res = client.upload_documents( + upload_doc_res = client.documents.upload( namespace_name=text_ns_name, documents=docs_to_upload ) logger.info( "Upload documents response (queued):" f" {json.dumps(upload_doc_res, indent=2)}" ) + if not upload_doc_res.get("submitted_ids"): + logger.info( + "Note: submitted_ids can be empty when these document IDs were" + " already ingested (re-runs). Search may still return existing" + " chunks." + ) except (NamespaceNotFound, InvalidInputError) as e: logger.error(f"Could not upload documents to '{text_ns_name}': {e}") except Exception as e: @@ -152,6 +159,7 @@ def run_quickstart(): random_vector = [ random.uniform(-1.0, 1.0) for _ in range(vector_dim) ] # Generate random vector + # Any keys other than id and vector are treated as metadata (flat). vectors_to_upload.append( { "id": vec_id, @@ -162,7 +170,7 @@ def run_quickstart(): } ) try: - upload_vec_res = client.upload_vectors( + upload_vec_res = client.vectors.upload( namespace_name=vector_ns_name, vectors=vectors_to_upload ) logger.info( @@ -191,7 +199,7 @@ def run_quickstart(): " interaction'" ) try: - text_search_res = client.search( + text_search_res = client.similarity_search.query( namespaces=[text_ns_name], query="API interaction", # Text query top_k=2, @@ -214,7 +222,7 @@ def run_quickstart(): try: # Generate a new random query vector query_vector = [random.uniform(-1.0, 1.0) for _ in range(vector_dim)] - vector_search_res = client.search( + vector_search_res = client.similarity_search.query( namespaces=[vector_ns_name], query=query_vector, # Vector query top_k=2, @@ -238,7 +246,7 @@ def run_quickstart(): f" '{text_ns_name}'..." ) try: - del_doc_res = client.delete_documents( + del_doc_res = client.documents.delete( namespace_name=text_ns_name, ids=[doc_id_to_delete] ) logger.info( @@ -258,7 +266,7 @@ def run_quickstart(): f" '{vector_ns_name}'..." ) try: - del_vec_res = client.delete_vectors( + del_vec_res = client.vectors.delete( namespace_name=vector_ns_name, ids=[vec_id_to_delete] ) logger.info( @@ -275,7 +283,7 @@ def run_quickstart(): # --- 8. Cleanup: Delete Namespaces (Optional - uncomment to run) --- # logger.info(f"[Step 8 - Cleanup] Deleting namespace: {text_ns_name}") # try: - # client.delete_namespace(text_ns_name) + # client.namespaces.delete(namespace_name=text_ns_name) # except NamespaceNotFound: # logger.warning(f"Namespace '{text_ns_name}' likely already deleted or never created.") # noqa: E501 # except Exception as e: @@ -283,7 +291,7 @@ def run_quickstart(): # logger.info(f"[Step 8 - Cleanup] Deleting namespace: {vector_ns_name}") # try: - # client.delete_namespace(vector_ns_name) + # client.namespaces.delete(namespace_name=vector_ns_name) # except NamespaceNotFound: # logger.warning(f"Namespace '{vector_ns_name}' likely already deleted or never created.") # noqa: E501 # except Exception as e: diff --git a/moorcheh_sdk/_client.py b/moorcheh_sdk/_client.py index 8b55653..99bad33 100644 --- a/moorcheh_sdk/_client.py +++ b/moorcheh_sdk/_client.py @@ -5,7 +5,6 @@ import httpx from ._base_client import AsyncAPIClient, SyncAPIClient -from ._legacy_client import LegacyClientMixin from .exceptions import ( APIError, AuthenticationError, @@ -27,13 +26,14 @@ Vectors, ) from .types import Body, Query, Timeout +from .utils.casing import transform_keys_to_snake_case from .utils.constants import DEFAULT_BASE_URL from .utils.logging import setup_logging logger = setup_logging(__name__) -class MoorchehClient(SyncAPIClient, LegacyClientMixin): +class MoorchehClient(SyncAPIClient): """ Moorcheh Python SDK client for interacting with the Moorcheh Semantic Search API v1. @@ -148,11 +148,15 @@ def _request( endpoint = "/" + endpoint try: + normalized_json_data = cast( + Body | None, transform_keys_to_snake_case(json_data) + ) + normalized_params = cast(Query | None, transform_keys_to_snake_case(params)) response = self.request( method=method, path=endpoint, - json=json_data, - params=params, + json=normalized_json_data, + params=normalized_params, ) logger.debug(f"Received response with status code: {response.status_code}") @@ -174,9 +178,16 @@ def _request( ) raise MoorchehError(f"Network or request error: {req_e}") from req_e except MoorchehError as sdk_err: - logger.error( - f"SDK Error during request to {endpoint}: {sdk_err}", exc_info=True - ) + if isinstance(sdk_err, ConflictError): + logger.warning( + "Request to %s returned conflict (caller may handle): %s", + endpoint, + sdk_err, + ) + else: + logger.error( + f"SDK Error during request to {endpoint}: {sdk_err}", exc_info=True + ) raise except Exception as e: logger.error( @@ -220,7 +231,7 @@ def _process_response( if not response.content: logger.debug("Response content is empty, returning empty dict.") return {} - json_response = response.json() + json_response = transform_keys_to_snake_case(response.json()) logger.debug(f"Decoded JSON response: {json_response}") return cast(dict[str, Any], json_response) except Exception as json_e: @@ -240,11 +251,16 @@ def _process_response( return None # Should not be reached def _handle_error_response(self, response: httpx.Response, endpoint: str) -> None: - # Log error responses before raising exceptions - logger.warning( - f"Request to {endpoint} failed with status {response.status_code}." - f" Response text: {response.text}" - ) + # Log error responses before raising exceptions (409 logged in _request when re-raised) + if response.status_code != 409: + logger.warning( + f"Request to {endpoint} failed with status {response.status_code}." + f" Response text: {response.text}" + ) + else: + logger.debug( + "Request to %s conflict response body: %s", endpoint, response.text + ) if response.status_code == 400: raise InvalidInputError(message=f"Bad Request: {response.text}") @@ -382,11 +398,15 @@ async def _request( endpoint = "/" + endpoint try: + normalized_json_data = cast( + Body | None, transform_keys_to_snake_case(json_data) + ) + normalized_params = cast(Query | None, transform_keys_to_snake_case(params)) response = await self.request( method=method, path=endpoint, - json=json_data, - params=params, + json=normalized_json_data, + params=normalized_params, ) logger.debug(f"Received response with status code: {response.status_code}") @@ -408,9 +428,16 @@ async def _request( ) raise MoorchehError(f"Network or request error: {req_e}") from req_e except MoorchehError as sdk_err: - logger.error( - f"SDK Error during request to {endpoint}: {sdk_err}", exc_info=True - ) + if isinstance(sdk_err, ConflictError): + logger.warning( + "Request to %s returned conflict (caller may handle): %s", + endpoint, + sdk_err, + ) + else: + logger.error( + f"SDK Error during request to {endpoint}: {sdk_err}", exc_info=True + ) raise except Exception as e: logger.error( @@ -454,7 +481,7 @@ def _process_response( if not response.content: logger.debug("Response content is empty, returning empty dict.") return {} - json_response = response.json() + json_response = transform_keys_to_snake_case(response.json()) logger.debug(f"Decoded JSON response: {json_response}") return cast(dict[str, Any], json_response) except Exception as json_e: @@ -474,11 +501,16 @@ def _process_response( return None # Should not be reached def _handle_error_response(self, response: httpx.Response, endpoint: str) -> None: - # Log error responses before raising exceptions - logger.warning( - f"Request to {endpoint} failed with status {response.status_code}." - f" Response text: {response.text}" - ) + # Log error responses before raising exceptions (409 logged in _request when re-raised) + if response.status_code != 409: + logger.warning( + f"Request to {endpoint} failed with status {response.status_code}." + f" Response text: {response.text}" + ) + else: + logger.debug( + "Request to %s conflict response body: %s", endpoint, response.text + ) if response.status_code == 400: raise InvalidInputError(message=f"Bad Request: {response.text}") diff --git a/moorcheh_sdk/_legacy_client.py b/moorcheh_sdk/_legacy_client.py deleted file mode 100644 index f399d3b..0000000 --- a/moorcheh_sdk/_legacy_client.py +++ /dev/null @@ -1,389 +0,0 @@ -import warnings -from typing import TYPE_CHECKING, Protocol - -from .types import ( - AnswerResponse, - ChatHistoryItem, - Document, - DocumentDeleteResponse, - DocumentGetResponse, - DocumentUploadResponse, - NamespaceCreateResponse, - NamespaceListResponse, - SearchResponse, - Vector, - VectorDeleteResponse, - VectorUploadResponse, -) - -if TYPE_CHECKING: - from .resources import Answer, Documents, Namespaces, Search, Vectors - - class ClientProtocol(Protocol): - namespaces: Namespaces - documents: Documents - vectors: Vectors - similarity_search: Search - answer: Answer - - -class LegacyClientMixin: - """ - Mixin class containing deprecated methods for MoorchehClient. - These methods are maintained for backward compatibility but delegate - to the new resource-based structure. - """ - - def create_namespace( - self: "ClientProtocol", - namespace_name: str, - type: str, - vector_dimension: int | None = None, - ) -> NamespaceCreateResponse: - """ - [DEPRECATED] Creates a new namespace. - - Use `client.namespaces.create` instead. - - Args: - namespace_name: A unique name for the namespace. - type: The type of namespace ("text" or "vector"). - vector_dimension: The dimension of vectors (required if type="vector"). - - Returns: - A dictionary containing the created namespace details. - - Structure: - { - "message": str, - "namespace_name": str, - "type": str, - "vector_dimension": int | None - } - """ - warnings.warn( - "create_namespace is deprecated and will be removed in a future version. " - "Use client.namespaces.create instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.namespaces.create( - namespace_name=namespace_name, type=type, vector_dimension=vector_dimension - ) - - def delete_namespace(self: "ClientProtocol", namespace_name: str) -> None: - """ - [DEPRECATED] Deletes a namespace and all its data. - - Use `client.namespaces.delete` instead. - - Args: - namespace_name: The name of the namespace to delete. - """ - warnings.warn( - "delete_namespace is deprecated and will be removed in a future version. " - "Use client.namespaces.delete instead.", - DeprecationWarning, - stacklevel=2, - ) - self.namespaces.delete(namespace_name=namespace_name) - - def list_namespaces(self: "ClientProtocol") -> NamespaceListResponse: - """ - [DEPRECATED] Lists all available namespaces. - - Use `client.namespaces.list` instead. - - Returns: - A dictionary containing the list of namespaces. - - Structure: - { - "namespaces": [ - { - "namespace_name": str, - "type": "text" | "vector", - "itemCount": int, - "vector_dimension": int | None - } - ], - "execution_time": float - } - """ - warnings.warn( - "list_namespaces is deprecated and will be removed in a future version. " - "Use client.namespaces.list instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.namespaces.list() - - def upload_documents( - self: "ClientProtocol", namespace_name: str, documents: list[Document] - ) -> DocumentUploadResponse: - """ - [DEPRECATED] Uploads text documents to a text-based namespace. - - Use `client.documents.upload` instead. - - Args: - namespace_name: The name of the target text-based namespace. - documents: A list of dictionaries representing the documents. - Each dictionary must contain: - - "id" (str | int): Unique identifier for the document. - - "text" (str): The text content to embed. - - "metadata" (dict, optional): Additional metadata. - - Returns: - A dictionary confirming the documents were queued. - - Structure: - { - "status": "queued", - "submitted_ids": list[str | int] - } - """ - warnings.warn( - "upload_documents is deprecated and will be removed in a future version. " - "Use client.documents.upload instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.documents.upload(namespace_name=namespace_name, documents=documents) - - def get_documents( - self: "ClientProtocol", namespace_name: str, ids: list[str | int] - ) -> DocumentGetResponse: - """ - [DEPRECATED] Retrieves documents by their IDs from a text-based namespace. - - Use `client.documents.get` instead. - - Args: - namespace_name: The name of the text-based namespace. - ids: A list of document IDs to retrieve (max 100). - - Returns: - A dictionary containing the retrieved documents. - - Structure: - { - "documents": [ - { - "id": str | int, - "text": str, - "metadata": dict - } - ] - } - """ - warnings.warn( - "get_documents is deprecated and will be removed in a future version. " - "Use client.documents.get instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.documents.get(namespace_name=namespace_name, ids=ids) - - def upload_vectors( - self: "ClientProtocol", namespace_name: str, vectors: list[Vector] - ) -> VectorUploadResponse: - """ - [DEPRECATED] Uploads pre-computed vectors to a vector-based namespace. - - Use `client.vectors.upload` instead. - - Args: - namespace_name: The name of the target vector-based namespace. - vectors: A list of dictionaries representing the vectors. - Each dictionary must contain: - - "id" (str | int): Unique identifier for the vector. - - "vector" (list[float]): The vector embedding. - - "metadata" (dict, optional): Additional metadata. - - Returns: - A dictionary confirming the result of the upload. - - Structure: - { - "status": "success" | "partial", - "vector_ids_processed": list[str | int], - "errors": list[dict] - } - """ - warnings.warn( - "upload_vectors is deprecated and will be removed in a future version. " - "Use client.vectors.upload instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.vectors.upload(namespace_name=namespace_name, vectors=vectors) - - def search( - self: "ClientProtocol", - namespaces: list[str], - query: str | list[float], - top_k: int = 10, - threshold: float | None = 0.25, - kiosk_mode: bool = False, - ) -> SearchResponse: - """ - [DEPRECATED] Performs a semantic search across namespaces. - - Use `client.similarity_search.query` instead. - - Args: - namespaces: A list of namespace names to search within. - query: The search query (text string or vector list). - top_k: The maximum number of results to return. Defaults to 10. - threshold: Minimum similarity score (0-1). Defaults to 0.25. - kiosk_mode: Enable strict filtering. Defaults to False. - - Returns: - A dictionary containing search results. - - Structure: - { - "results": [ - { - "id": str | int, - "score": float, - "text": str, # Only for text namespaces - "metadata": dict - } - ], - "execution_time": float - } - """ - warnings.warn( - "search is deprecated and will be removed in a future version. " - "Use client.similarity_search.query instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.similarity_search.query( - namespaces=namespaces, - query=query, - top_k=top_k, - threshold=threshold, - kiosk_mode=kiosk_mode, - ) - - def get_generative_answer( - self: "ClientProtocol", - namespace: str, - query: str, - top_k: int = 5, - ai_model: str = "anthropic.claude-sonnet-4-6", - chat_history: list[ChatHistoryItem] | None = None, - temperature: float = 0.7, - header_prompt: str | None = None, - footer_prompt: str | None = None, - ) -> AnswerResponse: - """ - [DEPRECATED] Generates an AI answer based on a search query within a namespace. - - Use `client.answer.generate` instead. - - Args: - namespace: The name of the text-based namespace to search within. - query: The question or prompt to answer. - top_k: The number of search results to use as context. Defaults to 5. - ai_model: The identifier of the LLM to use. - Defaults to "anthropic.claude-sonnet-4-6". - chat_history: Optional list of previous conversation turns for context. - Each item should be a dictionary. Defaults to None. - temperature: The sampling temperature for the LLM (0.0 to 1.0). - Higher values introduce more randomness. Defaults to 0.7. - header_prompt: Optional header prompt to be used in the LLM. - Defaults to None. - footer_prompt: Optional footer prompt to be used in the LLM. - Defaults to None. - - Returns: - A dictionary containing the generated answer and metadata. - - Structure: - { - "answer": str, - "model": str, - "contextCount": int, - "query": str - } - """ - warnings.warn( - "get_generative_answer is deprecated and will be removed in a future version. " - "Use client.answer.generate instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.answer.generate( - namespace=namespace, - query=query, - top_k=top_k, - ai_model=ai_model, - chat_history=chat_history, - temperature=temperature, - header_prompt=header_prompt, - footer_prompt=footer_prompt, - ) - - def delete_documents( - self: "ClientProtocol", namespace_name: str, ids: list[str | int] - ) -> DocumentDeleteResponse: - """ - [DEPRECATED] Deletes documents by their IDs from a text-based namespace. - - Use `client.documents.delete` instead. - - Args: - namespace_name: The name of the text-based namespace. - ids: A list of document IDs to delete. - - Returns: - A dictionary confirming the deletion status. - - Structure: - { - "status": "success" | "partial", - "deleted_ids": list[str | int], - "errors": list[dict] - } - """ - warnings.warn( - "delete_documents is deprecated and will be removed in a future version. " - "Use client.documents.delete instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.documents.delete(namespace_name=namespace_name, ids=ids) - - def delete_vectors( - self: "ClientProtocol", namespace_name: str, ids: list[str | int] - ) -> VectorDeleteResponse: - """ - [DEPRECATED] Deletes vectors by their IDs from a vector-based namespace. - - Use `client.vectors.delete` instead. - - Args: - namespace_name: The name of the vector-based namespace. - ids: A list of vector IDs to delete. - - Returns: - A dictionary confirming the deletion status. - - Structure: - { - "status": "success" | "partial", - "deleted_ids": list[str | int], - "errors": list[dict] - } - """ - warnings.warn( - "delete_vectors is deprecated and will be removed in a future version. " - "Use client.vectors.delete instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.vectors.delete(namespace_name=namespace_name, ids=ids) diff --git a/moorcheh_sdk/resources/answer.py b/moorcheh_sdk/resources/answer.py index a5d6c14..5cdb3be 100644 --- a/moorcheh_sdk/resources/answer.py +++ b/moorcheh_sdk/resources/answer.py @@ -56,7 +56,7 @@ def generate( { "answer": str, "model": str, - "contextCount": int, + "context_count": int, "query": str } @@ -119,14 +119,14 @@ def generate( payload: dict[str, Any] = { "namespace": namespace, "query": query, - "aiModel": ai_model, - "chatHistory": chat_history if chat_history is not None else [], + "ai_model": ai_model, + "chat_history": chat_history if chat_history is not None else [], "temperature": temperature, - "headerPrompt": header_prompt if header_prompt is not None else "", - "footerPrompt": footer_prompt if footer_prompt is not None else "", + "header_prompt": header_prompt if header_prompt is not None else "", + "footer_prompt": footer_prompt if footer_prompt is not None else "", } if structured_response is not None: - payload["structuredResponse"] = structured_response + payload["structured_response"] = structured_response if namespace: payload["type"] = "text" # Hardcoded as per API design payload["top_k"] = top_k if top_k is not None else 5 @@ -266,14 +266,14 @@ async def generate( payload: dict[str, Any] = { "namespace": namespace, "query": query, - "aiModel": ai_model, - "chatHistory": chat_history if chat_history is not None else [], + "ai_model": ai_model, + "chat_history": chat_history if chat_history is not None else [], "temperature": temperature, - "headerPrompt": header_prompt if header_prompt is not None else "", - "footerPrompt": footer_prompt if footer_prompt is not None else "", + "header_prompt": header_prompt if header_prompt is not None else "", + "footer_prompt": footer_prompt if footer_prompt is not None else "", } if structured_response is not None: - payload["structuredResponse"] = structured_response + payload["structured_response"] = structured_response if namespace: payload["type"] = "text" # Hardcoded as per API design payload["top_k"] = top_k if top_k is not None else 5 diff --git a/moorcheh_sdk/resources/documents.py b/moorcheh_sdk/resources/documents.py index 6f506a7..4cc9dd1 100644 --- a/moorcheh_sdk/resources/documents.py +++ b/moorcheh_sdk/resources/documents.py @@ -16,6 +16,7 @@ DocumentDeleteResponse, DocumentGetResponse, DocumentUploadResponse, + FetchTextDataResponse, FileDeleteResponse, FileUploadResponse, ) @@ -28,6 +29,20 @@ logger = setup_logging(__name__) +def _deletion_processed_count(response: dict) -> int: + raw = response.get("actual_deletions") + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + pass + for key in ("deleted_ids", "requested_ids"): + ids = response.get(key) + if isinstance(ids, list): + return len(ids) + return 0 + + class Documents(BaseResource): @required_args( ["namespace_name", "documents"], @@ -197,6 +212,44 @@ def get(self, namespace_name: str, ids: list[str | int]) -> DocumentGetResponse: ) return cast(DocumentGetResponse, response_data) + @required_args(["namespace_name"], types={"namespace_name": str}) + def fetch_text_data(self, namespace_name: str) -> FetchTextDataResponse: + """ + Lists stored text and summary chunks for a text namespace (up to 100 per request). + + Use this to export or display stored chunks, or for RAG. Only namespaces with + ``type == "text"`` are supported. This differs from :meth:`get`, which retrieves + full documents by **ID** via ``POST .../documents/get``. + + Args: + namespace_name: The name of the target text-based namespace. + + Returns: + A dictionary with ``status``, ``message``, ``namespace``, ``statistics``, + ``items`` (list of chunks with ``text``, ``metadata``, ``created_at``, + ``is_summary``, etc.), and ``execution_time``. Response keys use snake_case. + + Raises: + InvalidInputError: If ``namespace_name`` is invalid. + NamespaceNotFound: If the namespace does not exist (404). + AuthenticationError: If authentication fails (401/403). + APIError: For other API errors. + MoorchehError: For network issues. + """ + logger.info(f"Fetching text data from namespace '{namespace_name}'...") + endpoint = f"/namespaces/{namespace_name}/documents/fetch-text-data" + response_data = self._client._request("GET", endpoint, expected_status=200) + if not isinstance(response_data, dict): + logger.error("Fetch text data response was not a dictionary.") + raise APIError( + message="Unexpected response format from fetch text data endpoint." + ) + item_count = len(response_data.get("items", [])) + logger.info( + f"Fetched {item_count} text item(s) from namespace '{namespace_name}'." + ) + return cast(FetchTextDataResponse, response_data) + @required_args( ["namespace_name", "ids"], types={"namespace_name": str, "ids": list} ) @@ -250,7 +303,7 @@ def delete( message="Unexpected response format from delete documents endpoint." ) - deleted_count = len(response_data.get("deleted_ids", [])) + deleted_count = _deletion_processed_count(response_data) error_count = len(response_data.get("errors", [])) logger.info( f"Delete documents from '{namespace_name}' completed. Status:" @@ -290,8 +343,8 @@ def upload_file( "success": bool, "message": str, "namespace": str, - "fileName": str, - "fileSize": int + "file_name": str, + "file_size": int } Raises: @@ -398,18 +451,18 @@ def upload_file( response_data = self._client._request( method="POST", endpoint=endpoint, - json_data={"fileName": file_name}, + json_data={"file_name": file_name}, expected_status=200, ) if not isinstance(response_data, dict): raise APIError(message="Upload URL response was not a dictionary.") - upload_url = response_data.get("uploadUrl") - content_type = response_data.get("contentType") + upload_url = response_data.get("upload_url") + content_type = response_data.get("content_type") if not upload_url or not content_type: raise APIError( - message="Upload URL response missing 'uploadUrl' or 'contentType'." + message="Upload URL response missing 'upload_url' or 'content_type'." ) # Upload raw bytes to the presigned S3 URL. @@ -435,8 +488,8 @@ def upload_file( "success": True, "message": "File uploaded successfully", "namespace": namespace_name, - "fileName": file_name, - "fileSize": file_size or 0, + "file_name": file_name, + "file_size": file_size or 0, }, ) else: @@ -517,7 +570,7 @@ def delete_files( "namespace": str, "results": [ { - "fileName": str, + "file_name": str, "status": str, "message": str } @@ -555,7 +608,7 @@ def delete_files( response_data = self._client._request( method="DELETE", endpoint=endpoint, - json_data={"fileNames": file_names}, + json_data={"file_names": file_names}, expected_status=200, alt_success_status=207, ) @@ -743,6 +796,42 @@ async def get( logger.info(f"Successfully retrieved {retrieved_count} document(s).") return cast(DocumentGetResponse, response_data) + @required_args(["namespace_name"], types={"namespace_name": str}) + async def fetch_text_data(self, namespace_name: str) -> FetchTextDataResponse: + """ + Lists stored text and summary chunks for a text namespace (up to 100 per request). + + Async counterpart of :meth:`Documents.fetch_text_data`. + + Args: + namespace_name: The name of the target text-based namespace. + + Returns: + Same structure as :meth:`Documents.fetch_text_data` (snake_case keys). + + Raises: + InvalidInputError: If ``namespace_name`` is invalid. + NamespaceNotFound: If the namespace does not exist (404). + AuthenticationError: If authentication fails (401/403). + APIError: For other API errors. + MoorchehError: For network issues. + """ + logger.info(f"Fetching text data from namespace '{namespace_name}'...") + endpoint = f"/namespaces/{namespace_name}/documents/fetch-text-data" + response_data = await self._client._request( + "GET", endpoint, expected_status=200 + ) + if not isinstance(response_data, dict): + logger.error("Fetch text data response was not a dictionary.") + raise APIError( + message="Unexpected response format from fetch text data endpoint." + ) + item_count = len(response_data.get("items", [])) + logger.info( + f"Fetched {item_count} text item(s) from namespace '{namespace_name}'." + ) + return cast(FetchTextDataResponse, response_data) + @required_args( ["namespace_name", "ids"], types={"namespace_name": str, "ids": list} ) @@ -796,9 +885,14 @@ async def delete( message="Unexpected response format from delete documents endpoint." ) + deleted_count = _deletion_processed_count(response_data) + error_count = len(response_data.get("errors", [])) logger.info( - f"Delete operation completed with status: {response_data.get('status')}" + f"Delete documents from '{namespace_name}' completed. Status:" + f" {response_data.get('status')}, Deleted: {deleted_count}, Errors:" + f" {error_count}" ) + return cast(DocumentDeleteResponse, response_data) @required_args(["namespace_name"], types={"namespace_name": str}) @@ -828,8 +922,8 @@ async def upload_file( "success": bool, "message": str, "namespace": str, - "fileName": str, - "fileSize": int + "file_name": str, + "file_size": int } Raises: @@ -936,18 +1030,18 @@ async def upload_file( response_data = await self._client._request( method="POST", endpoint=endpoint, - json_data={"fileName": file_name}, + json_data={"file_name": file_name}, expected_status=200, ) if not isinstance(response_data, dict): raise APIError(message="Upload URL response was not a dictionary.") - upload_url = response_data.get("uploadUrl") - content_type = response_data.get("contentType") + upload_url = response_data.get("upload_url") + content_type = response_data.get("content_type") if not upload_url or not content_type: raise APIError( - message="Upload URL response missing 'uploadUrl' or 'contentType'." + message="Upload URL response missing 'upload_url' or 'content_type'." ) # Read the file without blocking the async loop. @@ -975,8 +1069,8 @@ async def upload_file( "success": True, "message": "File uploaded successfully", "namespace": namespace_name, - "fileName": file_name, - "fileSize": file_size or 0, + "file_name": file_name, + "file_size": file_size or 0, }, ) else: @@ -1057,7 +1151,7 @@ async def delete_files( "namespace": str, "results": [ { - "fileName": str, + "file_name": str, "status": str, "message": str } @@ -1095,7 +1189,7 @@ async def delete_files( response_data = await self._client._request( method="DELETE", endpoint=endpoint, - json_data={"fileNames": file_names}, + json_data={"file_names": file_names}, expected_status=200, alt_success_status=207, ) diff --git a/moorcheh_sdk/resources/namespaces.py b/moorcheh_sdk/resources/namespaces.py index 8b3710c..14f5144 100644 --- a/moorcheh_sdk/resources/namespaces.py +++ b/moorcheh_sdk/resources/namespaces.py @@ -116,7 +116,7 @@ def list(self) -> NamespaceListResponse: { "namespace_name": str, "type": "text" | "vector", - "itemCount": int, + "item_count": int, "vector_dimension": int | None } ], diff --git a/moorcheh_sdk/resources/vectors.py b/moorcheh_sdk/resources/vectors.py index 305600c..6359505 100644 --- a/moorcheh_sdk/resources/vectors.py +++ b/moorcheh_sdk/resources/vectors.py @@ -10,6 +10,20 @@ logger = setup_logging(__name__) +def _deletion_processed_count(response: dict) -> int: + raw = response.get("actual_deletions") + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + pass + for key in ("deleted_ids", "requested_ids"): + ids = response.get(key) + if isinstance(ids, list): + return len(ids) + return 0 + + class Vectors(BaseResource): @required_args( ["namespace_name", "vectors"], types={"namespace_name": str, "vectors": list} @@ -162,7 +176,7 @@ def delete(self, namespace_name: str, ids: list[str | int]) -> VectorDeleteRespo logger.error("Delete vectors response was not a dictionary.") raise APIError(message="Unexpected response format after deleting vectors.") - deleted_count = len(response_data.get("deleted_ids", [])) + deleted_count = _deletion_processed_count(response_data) error_count = len(response_data.get("errors", [])) logger.info( f"Delete vectors from '{namespace_name}' completed. Status:" @@ -340,7 +354,7 @@ async def delete( message="Unexpected response format from delete vectors endpoint." ) - deleted_count = len(response_data.get("deleted_ids", [])) + deleted_count = _deletion_processed_count(response_data) error_count = len(response_data.get("errors", [])) logger.info( diff --git a/moorcheh_sdk/types/__init__.py b/moorcheh_sdk/types/__init__.py index 3b725cf..fbd6866 100644 --- a/moorcheh_sdk/types/__init__.py +++ b/moorcheh_sdk/types/__init__.py @@ -10,9 +10,12 @@ DocumentDeleteResponse, DocumentGetResponse, DocumentUploadResponse, + FetchTextDataResponse, FileDeleteResponse, FileDeleteResult, FileUploadResponse, + TextDataItem, + TextDataStatistics, ) from .namespace import Namespace, NamespaceCreateResponse, NamespaceListResponse from .search import SearchResponse, SearchResult @@ -40,6 +43,9 @@ "DocumentUploadResponse", "DocumentDeleteResponse", "DocumentGetResponse", + "FetchTextDataResponse", + "TextDataItem", + "TextDataStatistics", "FileDeleteResponse", "FileDeleteResult", "FileUploadResponse", diff --git a/moorcheh_sdk/types/answer.py b/moorcheh_sdk/types/answer.py index 896c657..f3f1311 100644 --- a/moorcheh_sdk/types/answer.py +++ b/moorcheh_sdk/types/answer.py @@ -9,7 +9,7 @@ class ChatHistoryItem(TypedDict): class AnswerResponse(TypedDict): answer: str model: str - contextCount: int + context_count: int query: str - usedContext: bool | None - structuredData: dict | None + used_context: bool | None + structured_data: dict | None diff --git a/moorcheh_sdk/types/document.py b/moorcheh_sdk/types/document.py index f0acfe3..99cd317 100644 --- a/moorcheh_sdk/types/document.py +++ b/moorcheh_sdk/types/document.py @@ -22,16 +22,46 @@ class DocumentGetResponse(TypedDict): documents: list[Document] +class TextDataItem(TypedDict, total=False): + """One text or summary chunk from ``Documents.fetch_text_data``.""" + + id: str + text: str + metadata: dict[str, Any] | None + created_at: int + is_summary: bool + + +class TextDataStatistics(TypedDict, total=False): + total_items: int + total_text_chunks: int + total_summary_chunks: int + created_at_min: int + created_at_max: int + source_counts: dict[str, int] + + +class FetchTextDataResponse(TypedDict, total=False): + """Response from ``GET .../documents/fetch-text-data`` (keys normalized to snake_case).""" + + status: str + message: str + namespace: str + statistics: TextDataStatistics + items: list[TextDataItem] + execution_time: float + + class FileUploadResponse(TypedDict): success: bool message: str namespace: str - fileName: str - fileSize: int + file_name: str + file_size: int class FileDeleteResult(TypedDict): - fileName: str + file_name: str status: str message: str diff --git a/moorcheh_sdk/types/namespace.py b/moorcheh_sdk/types/namespace.py index 1a797fc..a443e61 100644 --- a/moorcheh_sdk/types/namespace.py +++ b/moorcheh_sdk/types/namespace.py @@ -4,7 +4,7 @@ class Namespace(TypedDict): namespace_name: str type: str - itemCount: int + item_count: int vector_dimension: int | None diff --git a/moorcheh_sdk/utils/casing.py b/moorcheh_sdk/utils/casing.py new file mode 100644 index 0000000..f9d0f4f --- /dev/null +++ b/moorcheh_sdk/utils/casing.py @@ -0,0 +1,27 @@ +import re +from typing import Any + +_FIRST_CAP_RE = re.compile(r"(.)([A-Z][a-z]+)") +_ALL_CAP_RE = re.compile(r"([a-z0-9])([A-Z])") + + +def to_snake_case(value: str) -> str: + """ + Convert camelCase/PascalCase strings to snake_case. + """ + s1 = _FIRST_CAP_RE.sub(r"\1_\2", value) + return _ALL_CAP_RE.sub(r"\1_\2", s1).lower() + + +def transform_keys_to_snake_case(value: Any) -> Any: + """ + Recursively convert dictionary keys to snake_case. + """ + if isinstance(value, dict): + return { + to_snake_case(str(key)): transform_keys_to_snake_case(val) + for key, val in value.items() + } + if isinstance(value, list): + return [transform_keys_to_snake_case(item) for item in value] + return value diff --git a/tests/resources/test_answer.py b/tests/resources/test_answer.py index 3a5d9c2..f42b31f 100644 --- a/tests/resources/test_answer.py +++ b/tests/resources/test_answer.py @@ -16,7 +16,7 @@ def test_get_generative_answer_success(client, mocker, mock_response): expected_response = { "answer": "Moorcheh is a semantic search engine.", "model": model, - "contextCount": 3, + "context_count": 3, "query": query, } mock_resp = mock_response(200, json_data=expected_response) @@ -31,11 +31,11 @@ def test_get_generative_answer_success(client, mocker, mock_response): "query": query, "top_k": 3, "type": "text", - "aiModel": model, - "chatHistory": [], + "ai_model": model, + "chat_history": [], "temperature": 0.7, - "headerPrompt": "", - "footerPrompt": "", + "header_prompt": "", + "footer_prompt": "", "kiosk_mode": False, } client._mock_httpx_instance.request.assert_called_once_with( @@ -53,7 +53,7 @@ def test_generate_answer_with_prompts(client, mocker, mock_response): expected_response = { "answer": "Moorcheh is great.", "model": "claude", - "contextCount": 1, + "context_count": 1, "query": query, } mock_resp = mock_response(200, json_data=expected_response) @@ -70,8 +70,8 @@ def test_generate_answer_with_prompts(client, mocker, mock_response): call_args = client._mock_httpx_instance.request.call_args payload = call_args.kwargs["json"] - assert payload["headerPrompt"] == header - assert payload["footerPrompt"] == footer + assert payload["header_prompt"] == header + assert payload["footer_prompt"] == footer @pytest.mark.parametrize( @@ -139,11 +139,11 @@ def test_empty_namespace(client, mocker, mock_response): expected_payload = { "namespace": "", "query": query, - "aiModel": "anthropic.claude-sonnet-4-6", - "chatHistory": [], + "ai_model": "anthropic.claude-sonnet-4-6", + "chat_history": [], "temperature": 0.7, - "headerPrompt": "", - "footerPrompt": "", + "header_prompt": "", + "footer_prompt": "", } client._mock_httpx_instance.request.assert_called_once_with( method="POST", url="/answer", json=expected_payload, params=None @@ -165,15 +165,15 @@ def test_structured_response(client, mocker, mock_response): expected_payload = { "namespace": "my_ns", "query": query, - "aiModel": "anthropic.claude-sonnet-4-6", - "chatHistory": [], + "ai_model": "anthropic.claude-sonnet-4-6", + "chat_history": [], "temperature": 0.7, - "headerPrompt": "", - "footerPrompt": "", + "header_prompt": "", + "footer_prompt": "", "type": "text", "top_k": 5, "kiosk_mode": False, - "structuredResponse": structured, + "structured_response": structured, } client._mock_httpx_instance.request.assert_called_once_with( method="POST", url="/answer", json=expected_payload, params=None @@ -195,12 +195,12 @@ def test_structured_response_with_empty_namespace(client, mocker, mock_response) expected_payload = { "namespace": "", "query": query, - "aiModel": "anthropic.claude-sonnet-4-6", - "chatHistory": [], + "ai_model": "anthropic.claude-sonnet-4-6", + "chat_history": [], "temperature": 0.7, - "headerPrompt": "", - "footerPrompt": "", - "structuredResponse": structured, + "header_prompt": "", + "footer_prompt": "", + "structured_response": structured, } client._mock_httpx_instance.request.assert_called_once_with( method="POST", url="/answer", json=expected_payload, params=None diff --git a/tests/resources/test_documents.py b/tests/resources/test_documents.py index 080531c..5b8362e 100644 --- a/tests/resources/test_documents.py +++ b/tests/resources/test_documents.py @@ -183,6 +183,53 @@ def test_get_documents_namespace_not_found(client, mocker, mock_response): client._mock_httpx_instance.request.assert_called_once() +def test_fetch_text_data_success(client, mocker, mock_response): + """Test GET fetch-text-data (list chunks for a text namespace).""" + expected_response = { + "status": "success", + "message": "Fetched 1 text items.", + "namespace": TEST_NAMESPACE, + "statistics": { + "total_items": 1, + "total_text_chunks": 1, + "total_summary_chunks": 0, + }, + "items": [ + { + "id": "chunk-1", + "text": "Hello", + "metadata": {"source": "a.txt"}, + "created_at": 1700000000000, + "is_summary": False, + } + ], + "execution_time": 0.05, + } + mock_resp = mock_response(200, json_data=expected_response) + client._mock_httpx_instance.request.return_value = mock_resp + + result = client.documents.fetch_text_data(namespace_name=TEST_NAMESPACE) + + client._mock_httpx_instance.request.assert_called_once_with( + method="GET", + url=f"/namespaces/{TEST_NAMESPACE}/documents/fetch-text-data", + json=None, + params=None, + ) + assert result == expected_response + + +def test_fetch_text_data_namespace_not_found(client, mocker, mock_response): + """Test fetch_text_data when namespace is missing.""" + error_text = f"Namespace '{TEST_NAMESPACE}' not found." + mock_resp = mock_response(404, text_data=error_text) + client._mock_httpx_instance.request.return_value = mock_resp + + with pytest.raises(NamespaceNotFound, match=error_text): + client.documents.fetch_text_data(namespace_name=TEST_NAMESPACE) + client._mock_httpx_instance.request.assert_called_once() + + # File Upload Tests def test_upload_file_success(client, mocker, mock_response, tmp_path): """Test successful file upload.""" @@ -191,16 +238,16 @@ def test_upload_file_success(client, mocker, mock_response, tmp_path): test_file.write_bytes(b"PDF content here") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/pdf", + "upload_url": "https://example.com/upload", + "content_type": "application/pdf", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": TEST_NAMESPACE, - "fileName": "test_document.pdf", - "fileSize": len(test_file.read_bytes()), + "file_name": "test_document.pdf", + "file_size": len(test_file.read_bytes()), } client._mock_httpx_instance.request.side_effect = [ mock_response(200, json_data=upload_url_data), @@ -218,7 +265,7 @@ def test_upload_file_success(client, mocker, mock_response, tmp_path): second_call = client._mock_httpx_instance.request.call_args_list[1] assert second_call.kwargs["method"] == "PUT" - assert second_call.kwargs["url"] == upload_url_data["uploadUrl"] + assert second_call.kwargs["url"] == upload_url_data["upload_url"] assert result == expected_response @@ -228,16 +275,16 @@ def test_upload_file_with_path_object(client, mocker, mock_response, tmp_path): test_file.write_text("Text content") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "text/plain", + "upload_url": "https://example.com/upload", + "content_type": "text/plain", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": TEST_NAMESPACE, - "fileName": "document.txt", - "fileSize": len(test_file.read_bytes()), + "file_name": "document.txt", + "file_size": len(test_file.read_bytes()), } client._mock_httpx_instance.request.side_effect = [ mock_response(200, json_data=upload_url_data), @@ -258,8 +305,8 @@ def test_upload_file_with_file_like_object(client, mocker, mock_response, tmp_pa test_file.write_text('{"key": "value"}') upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/json", + "upload_url": "https://example.com/upload", + "content_type": "application/json", } client._mock_httpx_instance.request.side_effect = [ @@ -273,8 +320,8 @@ def test_upload_file_with_file_like_object(client, mocker, mock_response, tmp_pa "success": True, "message": "File uploaded successfully", "namespace": TEST_NAMESPACE, - "fileName": f.name, - "fileSize": len(test_file.read_bytes()), + "file_name": f.name, + "file_size": len(test_file.read_bytes()), } result = client.documents.upload_file( namespace_name=TEST_NAMESPACE, file_path=f @@ -321,15 +368,15 @@ def test_upload_file_valid_extensions( test_file.write_bytes(b"content") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/json", + "upload_url": "https://example.com/upload", + "content_type": "application/json", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": TEST_NAMESPACE, - "fileName": f"test{file_extension}", - "fileSize": len(test_file.read_bytes()), + "file_name": f"test{file_extension}", + "file_size": len(test_file.read_bytes()), } client._mock_httpx_instance.request.side_effect = [ mock_response(200, json_data=upload_url_data), @@ -473,12 +520,12 @@ def test_delete_files_success_200(client, mocker, mock_response): "namespace": TEST_NAMESPACE, "results": [ { - "fileName": file_names[0], + "file_name": file_names[0], "status": "deleted", "message": "File deletion initiated successfully", }, { - "fileName": file_names[1], + "file_name": file_names[1], "status": "deleted", "message": "File deletion initiated successfully", }, @@ -494,7 +541,7 @@ def test_delete_files_success_200(client, mocker, mock_response): client._mock_httpx_instance.request.assert_called_once_with( method="DELETE", url=f"/namespaces/{TEST_NAMESPACE}/delete-file", - json={"fileNames": file_names}, + json={"file_names": file_names}, params=None, ) assert result == expected_response @@ -509,12 +556,12 @@ def test_delete_files_partial_success_207(client, mocker, mock_response): "namespace": TEST_NAMESPACE, "results": [ { - "fileName": file_names[0], + "file_name": file_names[0], "status": "deleted", "message": "File deletion initiated successfully", }, { - "fileName": file_names[1], + "file_name": file_names[1], "status": "not_found", "message": "File not found", }, @@ -530,7 +577,7 @@ def test_delete_files_partial_success_207(client, mocker, mock_response): client._mock_httpx_instance.request.assert_called_once_with( method="DELETE", url=f"/namespaces/{TEST_NAMESPACE}/delete-file", - json={"fileNames": file_names}, + json={"file_names": file_names}, params=None, ) assert result == expected_response diff --git a/tests/resources/test_namespaces.py b/tests/resources/test_namespaces.py index 59f2ed8..8aa600f 100644 --- a/tests/resources/test_namespaces.py +++ b/tests/resources/test_namespaces.py @@ -140,11 +140,11 @@ def test_list_namespaces_success(client, mocker, mock_response): """Test successfully listing namespaces.""" expected_response = { "namespaces": [ - {"namespace_name": "ns1", "type": "text", "itemCount": 100}, + {"namespace_name": "ns1", "type": "text", "item_count": 100}, { "namespace_name": "ns2", "type": "vector", - "itemCount": 500, + "item_count": 500, "vector_dimension": 128, }, ], diff --git a/tests/test_async_client.py b/tests/test_async_client.py index c100ecd..c44f6c4 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -38,7 +38,7 @@ async def test_namespaces_list(client): { "namespace_name": "test", "type": "text", - "itemCount": 0, + "item_count": 0, "vector_dimension": None, } ], @@ -81,6 +81,32 @@ async def test_documents_upload(client): assert kwargs["json"] == {"documents": documents} +@pytest.mark.asyncio +async def test_documents_fetch_text_data(client): + mock_response = { + "status": "success", + "message": "Fetched 0 text items.", + "namespace": "test", + "statistics": {"total_items": 0}, + "items": [], + "execution_time": 0.01, + } + + with patch.object(client, "request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = MagicMock( + status_code=200, json=lambda: mock_response + ) + + response = await client.documents.fetch_text_data(namespace_name="test") + + assert response == mock_response + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + assert kwargs["method"] == "GET" + assert kwargs["path"] == "/namespaces/test/documents/fetch-text-data" + assert kwargs["params"] is None + + @pytest.mark.asyncio async def test_search_query(client): mock_response = {"results": [], "execution_time": 0.1} @@ -148,11 +174,11 @@ async def test_answer_generate(client): "query": "hello", "top_k": 5, "type": "text", - "aiModel": "anthropic.claude-sonnet-4-6", - "chatHistory": [], + "ai_model": "anthropic.claude-sonnet-4-6", + "chat_history": [], "temperature": 0.7, - "headerPrompt": "", - "footerPrompt": "", + "header_prompt": "", + "footer_prompt": "", "kiosk_mode": False, } @@ -165,15 +191,15 @@ async def test_upload_file_success(client, tmp_path): test_file.write_bytes(b"PDF content here") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/pdf", + "upload_url": "https://example.com/upload", + "content_type": "application/pdf", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": "test", - "fileName": "test_document.pdf", - "fileSize": len(test_file.read_bytes()), + "file_name": "test_document.pdf", + "file_size": len(test_file.read_bytes()), } with patch.object(client, "request", new_callable=AsyncMock) as mock_request: @@ -196,11 +222,11 @@ async def test_upload_file_success(client, tmp_path): first_call = mock_request.call_args_list[0] assert first_call.kwargs["method"] == "POST" assert first_call.kwargs["path"] == "/namespaces/test/upload-url" - assert first_call.kwargs["json"] == {"fileName": "test_document.pdf"} + assert first_call.kwargs["json"] == {"file_name": "test_document.pdf"} second_call = mock_request.call_args_list[1] assert second_call.kwargs["method"] == "PUT" - assert second_call.kwargs["path"] == upload_url_data["uploadUrl"] + assert second_call.kwargs["path"] == upload_url_data["upload_url"] @pytest.mark.asyncio @@ -210,15 +236,15 @@ async def test_upload_file_with_path_object(client, tmp_path): test_file.write_text("Text content") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "text/plain", + "upload_url": "https://example.com/upload", + "content_type": "text/plain", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": "test", - "fileName": "document.txt", - "fileSize": len(test_file.read_bytes()), + "file_name": "document.txt", + "file_size": len(test_file.read_bytes()), } with patch.object(client, "request", new_callable=AsyncMock) as mock_request: @@ -247,8 +273,8 @@ async def test_upload_file_with_file_like_object(client, tmp_path): test_file.write_text('{"key": "value"}') upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/json", + "upload_url": "https://example.com/upload", + "content_type": "application/json", } with patch.object(client, "request", new_callable=AsyncMock) as mock_request: @@ -267,8 +293,8 @@ async def test_upload_file_with_file_like_object(client, tmp_path): "success": True, "message": "File uploaded successfully", "namespace": "test", - "fileName": f.name, - "fileSize": len(test_file.read_bytes()), + "file_name": f.name, + "file_size": len(test_file.read_bytes()), } response = await client.documents.upload_file( namespace_name="test", file_path=f @@ -314,15 +340,15 @@ async def test_upload_file_valid_extensions(client, tmp_path, file_extension): test_file.write_bytes(b"content") upload_url_data = { - "uploadUrl": "https://example.com/upload", - "contentType": "application/json", + "upload_url": "https://example.com/upload", + "content_type": "application/json", } expected_response = { "success": True, "message": "File uploaded successfully", "namespace": "test", - "fileName": f"test{file_extension}", - "fileSize": len(test_file.read_bytes()), + "file_name": f"test{file_extension}", + "file_size": len(test_file.read_bytes()), } with patch.object(client, "request", new_callable=AsyncMock) as mock_request: @@ -448,12 +474,12 @@ async def test_delete_files_success(client): "namespace": "test", "results": [ { - "fileName": file_names[0], + "file_name": file_names[0], "status": "deleted", "message": "File deletion initiated successfully", }, { - "fileName": file_names[1], + "file_name": file_names[1], "status": "deleted", "message": "File deletion initiated successfully", }, @@ -474,7 +500,7 @@ async def test_delete_files_success(client): args, kwargs = mock_request.call_args assert kwargs["method"] == "DELETE" assert kwargs["path"] == "/namespaces/test/delete-file" - assert kwargs["json"] == {"fileNames": file_names} + assert kwargs["json"] == {"file_names": file_names} @pytest.mark.asyncio @@ -487,12 +513,12 @@ async def test_delete_files_partial_success_207(client): "namespace": "test", "results": [ { - "fileName": file_names[0], + "file_name": file_names[0], "status": "deleted", "message": "File deletion initiated successfully", }, { - "fileName": file_names[1], + "file_name": file_names[1], "status": "not_found", "message": "File not found", }, @@ -513,7 +539,7 @@ async def test_delete_files_partial_success_207(client): args, kwargs = mock_request.call_args assert kwargs["method"] == "DELETE" assert kwargs["path"] == "/namespaces/test/delete-file" - assert kwargs["json"] == {"fileNames": file_names} + assert kwargs["json"] == {"file_names": file_names} @pytest.mark.parametrize( diff --git a/tests/test_client.py b/tests/test_client.py index f8cd5e1..f780762 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,5 @@ import os -from unittest.mock import patch +from unittest.mock import ANY, patch import httpx import pytest @@ -28,11 +28,12 @@ def test_client_initialization_success_with_key(mock_httpx_client): httpx.Client.assert_called_once_with( base_url="http://test.url", headers={ - "x-api-key": DUMMY_API_KEY, "Accept": "application/json", + "x-api-key": DUMMY_API_KEY, "User-Agent": f"moorcheh-python-sdk/{__version__}", }, timeout=30.0, # Default timeout + verify=ANY, ) client_instance.close() # Explicitly close to avoid resource warnings diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py deleted file mode 100644 index 0be64f1..0000000 --- a/tests/test_deprecation.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest - -from moorcheh_sdk import MoorchehClient -from tests.constants import DUMMY_API_KEY - - -def test_deprecated_methods(mock_httpx_client, mocker, mock_response): - """Test that deprecated methods issue a warning and call the new resource methods.""" - with MoorchehClient(api_key=DUMMY_API_KEY) as client: - client._mock_httpx_instance = mock_httpx_client - - mock_httpx_client.request.side_effect = [ - mock_response(201, json_data={}), # create_namespace - mock_response(200, json_data={}), # delete_namespace - mock_response(200, json_data={"namespaces": []}), # list_namespaces - mock_response(202, json_data={}), # upload_documents - mock_response(200, json_data={}), # get_documents - mock_response(201, json_data={}), # upload_vectors - mock_response(200, json_data={}), # search - mock_response(200, json_data={}), # get_generative_answer - mock_response(200, json_data={}), # delete_documents - mock_response(200, json_data={}), # delete_vectors - ] - - # 1. create_namespace - with pytest.warns(DeprecationWarning, match="create_namespace is deprecated"): - client.create_namespace("ns", "text") - - # 2. delete_namespace - with pytest.warns(DeprecationWarning, match="delete_namespace is deprecated"): - client.delete_namespace("ns") - - # 3. list_namespaces - with pytest.warns(DeprecationWarning, match="list_namespaces is deprecated"): - client.list_namespaces() - - # 4. upload_documents - with pytest.warns(DeprecationWarning, match="upload_documents is deprecated"): - client.upload_documents("ns", [{"id": "1", "text": "t"}]) - - # 5. get_documents - with pytest.warns(DeprecationWarning, match="get_documents is deprecated"): - client.get_documents("ns", ["1"]) - - # 6. upload_vectors - with pytest.warns(DeprecationWarning, match="upload_vectors is deprecated"): - client.upload_vectors("ns", [{"id": "1", "vector": [0.1]}]) - - # 7. search - with pytest.warns(DeprecationWarning, match="search is deprecated"): - client.search(["ns"], "query") - - # 8. get_generative_answer - with pytest.warns( - DeprecationWarning, match="get_generative_answer is deprecated" - ): - client.get_generative_answer("ns", "query") - - # 9. delete_documents - with pytest.warns(DeprecationWarning, match="delete_documents is deprecated"): - client.delete_documents("ns", ["1"]) - - # 10. delete_vectors - with pytest.warns(DeprecationWarning, match="delete_vectors is deprecated"): - client.delete_vectors("ns", ["1"])