diff --git a/aixplain/utils/config.py b/aixplain/utils/config.py index e4dba6e1..fa3bc7aa 100644 --- a/aixplain/utils/config.py +++ b/aixplain/utils/config.py @@ -20,9 +20,7 @@ logger = logging.getLogger(__name__) BACKEND_URL = os.getenv("BACKEND_URL", "https://platform-api.aixplain.com") -MODELS_RUN_URL = os.getenv( - "MODELS_RUN_URL", "https://models.aixplain.com/api/v1/execute" -) +MODELS_RUN_URL = os.getenv("MODELS_RUN_URL", "https://models.aixplain.com/api/v1/execute") # GET THE API KEY FROM CMD TEAM_API_KEY = os.getenv("TEAM_API_KEY", "") AIXPLAIN_API_KEY = os.getenv("AIXPLAIN_API_KEY", "") diff --git a/aixplain/utils/convert_datatype_utils.py b/aixplain/utils/convert_datatype_utils.py index 377c4705..0951b8c7 100644 --- a/aixplain/utils/convert_datatype_utils.py +++ b/aixplain/utils/convert_datatype_utils.py @@ -19,8 +19,8 @@ import json from pydantic import BaseModel -def dict_to_metadata(metadatas: List[Union[Dict, MetaData]]) -> None: +def dict_to_metadata(metadatas: List[Union[Dict, MetaData]]) -> None: """Convert all the Dicts to MetaData Args: @@ -38,26 +38,20 @@ def dict_to_metadata(metadatas: List[Union[Dict, MetaData]]) -> None: if isinstance(metadatas[i], dict): metadatas[i] = MetaData(**metadatas[i]) except TypeError: - raise TypeError(f"Data Asset Onboarding Error: One or more elements in the metadata_schema are not well-structured") + raise TypeError( + f"Data Asset Onboarding Error: One or more elements in the metadata_schema are not well-structured" + ) def normalize_expected_output(obj): if isinstance(obj, type) and issubclass(obj, BaseModel): - schema = ( - obj.model_json_schema() - if hasattr(obj, "model_json_schema") - else obj.schema() - ) + schema = obj.model_json_schema() if hasattr(obj, "model_json_schema") else obj.schema() return json.dumps(schema) if isinstance(obj, BaseModel): - return ( - obj.model_dump_json() if hasattr(obj, "model_dump_json") else obj.json() - ) + return obj.model_dump_json() if hasattr(obj, "model_dump_json") else obj.json() if isinstance(obj, (dict, str)) or obj is None: - return ( - obj if isinstance(obj, str) else json.dumps(obj) if obj is not None else obj - ) + return obj if isinstance(obj, str) else json.dumps(obj) if obj is not None else obj return json.dumps(obj) diff --git a/aixplain/utils/file_utils.py b/aixplain/utils/file_utils.py index 03fb6225..5076a099 100644 --- a/aixplain/utils/file_utils.py +++ b/aixplain/utils/file_utils.py @@ -153,7 +153,12 @@ def upload_data( url = urljoin(config.BACKEND_URL, "sdk/file/upload-url") if tags is None: tags = [] - payload = {"contentType": content_type, "originalName": file_name, "tags": ",".join(tags), "license": license.value} + payload = { + "contentType": content_type, + "originalName": file_name, + "tags": ",".join(tags), + "license": license.value, + } team_key = api_key or config.TEAM_API_KEY headers = {"Authorization": "token " + team_key} @@ -209,7 +214,7 @@ def upload_data( def s3_to_csv( s3_url: Text, - aws_credentials: Optional[Dict[Text, Text]] = {"AWS_ACCESS_KEY_ID": None, "AWS_SECRET_ACCESS_KEY": None} + aws_credentials: Optional[Dict[Text, Text]] = {"AWS_ACCESS_KEY_ID": None, "AWS_SECRET_ACCESS_KEY": None}, ) -> str: """Convert S3 directory contents to a CSV file with file listings. diff --git a/aixplain/utils/validation_utils.py b/aixplain/utils/validation_utils.py index 4477238b..028bd3d5 100644 --- a/aixplain/utils/validation_utils.py +++ b/aixplain/utils/validation_utils.py @@ -84,17 +84,17 @@ def dataset_onboarding_validation( metadata_spliting_schema = list(filter(lambda md: str(md.dsubtype) == "split", metadata_schema)) # validate the input and the output of the dataset - assert ( - len(input_schema) > 0 or len(input_ref_data) > 0 - ), "Data Asset Onboarding Error: You must specify an input data to onboard a dataset." + assert len(input_schema) > 0 or len(input_ref_data) > 0, ( + "Data Asset Onboarding Error: You must specify an input data to onboard a dataset." + ) input_dtype = input_schema[0].dtype if isinstance(input_schema[0], MetaData) else input_schema[0]["dtype"] if isinstance(input_dtype, DataType): input_dtype = input_dtype.value - assert ( - FunctionInputOutput.get(function) is not None and input_dtype in FunctionInputOutput[function]["input"] - ), f"Data Asset Onboarding Error: The input data type `{input_dtype}` is not compatible with the `{function}` function.\nThe expected input data type should be one of these data type: `{FunctionInputOutput[function]['input']}`." + assert FunctionInputOutput.get(function) is not None and input_dtype in FunctionInputOutput[function]["input"], ( + f"Data Asset Onboarding Error: The input data type `{input_dtype}` is not compatible with the `{function}` function.\nThe expected input data type should be one of these data type: `{FunctionInputOutput[function]['input']}`." + ) if len(output_schema) > 0: output_dtype = output_schema[0].dtype if isinstance(output_schema[0], MetaData) else output_schema[0]["dtype"] @@ -103,19 +103,21 @@ def dataset_onboarding_validation( assert ( FunctionInputOutput.get(function) is not None and output_dtype in FunctionInputOutput[function]["output"] - ), f"Data Asset Onboarding Error: The output data type `{output_dtype}` is not compatible with the `{function}` function.\nThe expected output data type should be one of these data type: `{FunctionInputOutput[function]['output']}`." + ), ( + f"Data Asset Onboarding Error: The output data type `{output_dtype}` is not compatible with the `{function}` function.\nThe expected output data type should be one of these data type: `{FunctionInputOutput[function]['output']}`." + ) # validate the splitting - assert ( - len(metadata_spliting_schema) < 2 - ), f"Data Asset Onboarding Error: Only 0 or 1 metadata of the split subtype can be added to the `metadata_schema`." - assert all( - str(mds.dtype) == "label" for mds in metadata_spliting_schema - ), f"Data Asset Onboarding Error: The `dtype` must be `label` for any splitting subtype." - - assert ( - content_path is not None or s3_link is not None - ), "Data Asset Onboarding Error: No path to content Data was provided. Please update `context_path` or `s3_link`." - assert (split_labels is not None and split_rate is not None) or ( - split_labels is None and split_rate is None - ), "Data Asset Onboarding Error: Make sure you set the split labels values as well as their rates." + assert len(metadata_spliting_schema) < 2, ( + f"Data Asset Onboarding Error: Only 0 or 1 metadata of the split subtype can be added to the `metadata_schema`." + ) + assert all(str(mds.dtype) == "label" for mds in metadata_spliting_schema), ( + f"Data Asset Onboarding Error: The `dtype` must be `label` for any splitting subtype." + ) + + assert content_path is not None or s3_link is not None, ( + "Data Asset Onboarding Error: No path to content Data was provided. Please update `context_path` or `s3_link`." + ) + assert (split_labels is not None and split_rate is not None) or (split_labels is None and split_rate is None), ( + "Data Asset Onboarding Error: Make sure you set the split labels values as well as their rates." + ) diff --git a/aixplain/v2/__init__.py b/aixplain/v2/__init__.py index 9da53df0..71a122d2 100644 --- a/aixplain/v2/__init__.py +++ b/aixplain/v2/__init__.py @@ -22,10 +22,12 @@ from .meta_agents import Debugger, DebugResult from .agent_progress import AgentProgressTracker, ProgressFormat from .api_key import APIKey, APIKeyLimits, APIKeyUsageLimit, TokenType +from .issue import IssueReporter, IssueSeverity from .exceptions import ( AixplainV2Error, ResourceError, APIError, + AixplainIssueError, ValidationError, TimeoutError, FileUploadError, @@ -85,6 +87,8 @@ "APIKeyLimits", "APIKeyUsageLimit", "TokenType", + "IssueReporter", + "IssueSeverity", # Progress tracking "AgentProgressTracker", "ProgressFormat", @@ -92,6 +96,7 @@ "AixplainV2Error", "ResourceError", "APIError", + "AixplainIssueError", "ValidationError", "TimeoutError", "FileUploadError", diff --git a/aixplain/v2/core.py b/aixplain/v2/core.py index 8666faee..fa23311a 100644 --- a/aixplain/v2/core.py +++ b/aixplain/v2/core.py @@ -15,6 +15,7 @@ from .meta_agents import Debugger from .api_key import APIKey from .rlm import RLM, RLMResult +from .issue import IssueReporter from . import enums @@ -28,6 +29,7 @@ DebuggerType = TypeVar("DebuggerType", bound=Debugger) APIKeyType = TypeVar("APIKeyType", bound=APIKey) RLMType = TypeVar("RLMType", bound=RLM) +IssueReporterType = TypeVar("IssueReporterType", bound=IssueReporter) class Aixplain: @@ -53,6 +55,7 @@ class Aixplain: Debugger: DebuggerType = None APIKey: APIKeyType = None RLM: RLMType = None + issue: IssueReporterType = None Function = enums.Function Supplier = enums.Supplier @@ -133,3 +136,4 @@ def init_resources(self) -> None: self.Debugger = type("Debugger", (Debugger,), {"context": self}) self.APIKey = type("APIKey", (APIKey,), {"context": self}) self.RLM = type("RLM", (RLM,), {"context": self}) + self.issue = IssueReporter(context=self) diff --git a/aixplain/v2/exceptions.py b/aixplain/v2/exceptions.py index 428a5b85..3693901c 100644 --- a/aixplain/v2/exceptions.py +++ b/aixplain/v2/exceptions.py @@ -61,6 +61,12 @@ def __init__( ) +class AixplainIssueError(APIError): + """Raised when SDK issue reporting fails.""" + + pass + + class ValidationError(AixplainV2Error): """Raised when validation fails.""" diff --git a/aixplain/v2/issue.py b/aixplain/v2/issue.py new file mode 100644 index 00000000..a40f29e9 --- /dev/null +++ b/aixplain/v2/issue.py @@ -0,0 +1,96 @@ +"""Issue reporting helpers for the V2 SDK.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, Optional, TYPE_CHECKING + +from .exceptions import APIError, AixplainIssueError + +if TYPE_CHECKING: + from .core import Aixplain + + +class IssueSeverity(str, Enum): + """Supported issue severity levels.""" + + SEV1 = "SEV1" + SEV2 = "SEV2" + SEV3 = "SEV3" + SEV4 = "SEV4" + + +class IssueReporter: + """submitting SDK issues to the backend.""" + + ISSUE_PATH = "/v1/issue" + + def __init__(self, context: "Aixplain") -> None: + """Initialize the issue reporter.""" + self.context = context + + def _issue_url(self) -> str: + """Build the full issue endpoint URL for the configured backend.""" + return f"{self.context.backend_url.rstrip('/')}{self.ISSUE_PATH}" + + def report(self, description: Optional[str], **kwargs: Any) -> str: + """Submit an issue report and return its ID.""" + self._validate_description(description) + + allowed_fields = { + "title", + "severity", + "tags", + "sdk_version", + "runtime_context", + "reporter_email", + } + unexpected_fields = sorted(set(kwargs) - allowed_fields) + if unexpected_fields: + raise AixplainIssueError( + f"Unsupported issue fields: {', '.join(unexpected_fields)}.", + ) + + severity = kwargs.get("severity") + if severity is not None: + self._validate_severity(severity) + + payload: Dict[str, Any] = {"description": description} + for key, value in kwargs.items(): + if value is not None: + payload[key] = value.value if isinstance(value, IssueSeverity) else value + + try: + response = self.context.client.post(self._issue_url(), json=payload) + except APIError as error: + raise AixplainIssueError( + error.message, + status_code=error.status_code, + response_data=error.response_data, + error=error.error, + ) from error + + issue_id = response.get("issue_id") + if not issue_id: + raise AixplainIssueError( + "Issue report accepted but no issue_id was returned.", + status_code=202, + response_data=response, + error="missing_issue_id", + ) + return issue_id + + @staticmethod + def _validate_description(description: Optional[str]) -> None: + if description is None: + raise AixplainIssueError("Field 'description' is required.", status_code=400) + + @staticmethod + def _validate_severity(severity: Any) -> None: + valid_values = {level.value for level in IssueSeverity} + resolved = severity.value if isinstance(severity, IssueSeverity) else severity + if resolved not in valid_values: + raise AixplainIssueError( + "severity must be one of: SEV1, SEV2, SEV3, SEV4.", + status_code=400, + ) diff --git a/post_process_docs.py b/post_process_docs.py index cb471ef5..dfc6c7f4 100644 --- a/post_process_docs.py +++ b/post_process_docs.py @@ -4,170 +4,168 @@ import re import json -def rename_files(docs_dir='docs/api-reference/python'): - """ - 1. Rename __init__.md files to init.md - 2. Remove leading underscores from filenames - """ - renamed_init_files = 0 - renamed_underscore_files = 0 - - # Walk through the docs directory - for root, _, files in os.walk(docs_dir): - for file in files: - # Rename __init__.md to init.md - if file == "__init__.md": - old_path = os.path.join(root, file) - new_path = os.path.join(root, "init.md") - os.rename(old_path, new_path) - renamed_init_files += 1 - - # Remove leading underscore from filenames - elif file.startswith('_') and file != "__init__.md": - old_path = os.path.join(root, file) - new_path = os.path.join(root, file[1:]) - os.rename(old_path, new_path) - renamed_underscore_files += 1 - - print(f"Renamed {renamed_init_files} __init__.md files to init.md") - print(f"Renamed {renamed_underscore_files} files by removing leading underscore") - -def process_content(docs_dir='docs/api-reference/python'): - """ - Process markdown content: - 1. Escape braces outside code blocks - """ - modified_files = 0 - - # Walk through the docs directory - for root, _, files in os.walk(docs_dir): - for file in files: - if file.endswith('.md'): - file_path = os.path.join(root, file) - - # Read the file - with open(file_path, 'r') as f: - content = f.read() - - # Process content - original_content = content - - # Escape braces outside code blocks - parts = re.split(r'(```.*?```)', content, flags=re.DOTALL) - for i in range(len(parts)): - if i % 2 == 0: # Outside code blocks - parts[i] = re.sub(r'(? init) - 2. Make top-level categories non-collapsible - 3. Add landing page link - """ - # Read the sidebar file - with open(sidebar_path, 'r') as f: - content = f.read() - - # 1. Fix sidebar references - init_replacements = content.count('__init__') - content = re.sub(r'/__init__"', r'/init"', content) - - # Write the intermediate changes - with open(sidebar_path, 'w') as f: - f.write(content) - - # Read as JSON for structural changes - with open(sidebar_path, 'r') as f: - sidebar_data = json.load(f) - - # 2. Make top-level categories non-collapsible - sidebar_data["collapsible"] = False - for item in sidebar_data.get("items", []): - if isinstance(item, dict) and item.get("type") == "category": - item["collapsible"] = False - - # 3. Add landing page link - sidebar_data["link"] = { - "type": "doc", - "id": "api-reference/python/python" - } - - # Write back the modified JSON - with open(sidebar_path, 'w') as f: - json.dump(sidebar_data, f, indent=2) - - print(f"Updated {init_replacements} __init__ references to init in sidebar") - print(f"Added collapsible: false to top-level categories") - print(f"Added landing page link to sidebar") + +def rename_files(docs_dir="docs/api-reference/python"): + """ + 1. Rename __init__.md files to init.md + 2. Remove leading underscores from filenames + """ + renamed_init_files = 0 + renamed_underscore_files = 0 + + # Walk through the docs directory + for root, _, files in os.walk(docs_dir): + for file in files: + # Rename __init__.md to init.md + if file == "__init__.md": + old_path = os.path.join(root, file) + new_path = os.path.join(root, "init.md") + os.rename(old_path, new_path) + renamed_init_files += 1 + + # Remove leading underscore from filenames + elif file.startswith("_") and file != "__init__.md": + old_path = os.path.join(root, file) + new_path = os.path.join(root, file[1:]) + os.rename(old_path, new_path) + renamed_underscore_files += 1 + + print(f"Renamed {renamed_init_files} __init__.md files to init.md") + print(f"Renamed {renamed_underscore_files} files by removing leading underscore") + + +def process_content(docs_dir="docs/api-reference/python"): + """ + Process markdown content: + 1. Escape braces outside code blocks + """ + modified_files = 0 + + # Walk through the docs directory + for root, _, files in os.walk(docs_dir): + for file in files: + if file.endswith(".md"): + file_path = os.path.join(root, file) + + # Read the file + with open(file_path, "r") as f: + content = f.read() + + # Process content + original_content = content + + # Escape braces outside code blocks + parts = re.split(r"(```.*?```)", content, flags=re.DOTALL) + for i in range(len(parts)): + if i % 2 == 0: # Outside code blocks + parts[i] = re.sub(r"(? init) + 2. Make top-level categories non-collapsible + 3. Add landing page link + """ + # Read the sidebar file + with open(sidebar_path, "r") as f: + content = f.read() + + # 1. Fix sidebar references + init_replacements = content.count("__init__") + content = re.sub(r'/__init__"', r'/init"', content) + + # Write the intermediate changes + with open(sidebar_path, "w") as f: + f.write(content) + + # Read as JSON for structural changes + with open(sidebar_path, "r") as f: + sidebar_data = json.load(f) + + # 2. Make top-level categories non-collapsible + sidebar_data["collapsible"] = False + for item in sidebar_data.get("items", []): + if isinstance(item, dict) and item.get("type") == "category": + item["collapsible"] = False + + # 3. Add landing page link + sidebar_data["link"] = {"type": "doc", "id": "api-reference/python/python"} + + # Write back the modified JSON + with open(sidebar_path, "w") as f: + json.dump(sidebar_data, f, indent=2) + + print(f"Updated {init_replacements} __init__ references to init in sidebar") + print(f"Added collapsible: false to top-level categories") + print(f"Added landing page link to sidebar") + def main(): - """ - Execute all post-processing steps for documentation - """ - print("Starting documentation post-processing...") + """ + Execute all post-processing steps for documentation + """ + print("Starting documentation post-processing...") + + # Create docs directory if it doesn't exist + os.makedirs("docs/api-reference/python", exist_ok=True) - # Create docs directory if it doesn't exist - os.makedirs('docs/api-reference/python', exist_ok=True) + # 1. Rename files + rename_files() - # 1. Rename files - rename_files() + # 2. Process content + process_content() - # 2. Process content - process_content() + # 3. Mark empty init files + mark_empty_init_files() - # 3. Mark empty init files - mark_empty_init_files() + # 4. Configure sidebar + configure_sidebar() - # 4. Configure sidebar - configure_sidebar() + print("Documentation post-processing complete!") - print("Documentation post-processing complete!") if __name__ == "__main__": - main() + main() diff --git a/tests/functional/agent/sql_tool_functional_test.py b/tests/functional/agent/sql_tool_functional_test.py index 3e758486..5d38eb0f 100644 --- a/tests/functional/agent/sql_tool_functional_test.py +++ b/tests/functional/agent/sql_tool_functional_test.py @@ -90,7 +90,9 @@ def test_create_sql_tool_source_type_handling(tmp_path): assert isinstance(tool_enum, SQLTool) # Test invalid type - with pytest.raises(SQLToolError, match="Source type must be either a string or DatabaseSourceType enum, got "): + with pytest.raises( + SQLToolError, match="Source type must be either a string or DatabaseSourceType enum, got " + ): AgentFactory.create_sql_tool( name="Test SQL", description="Test", source=db_path, source_type=123, schema="test" ) diff --git a/tests/functional/benchmark/benchmark_error_test.py b/tests/functional/benchmark/benchmark_error_test.py index cfd98447..cf0537c0 100644 --- a/tests/functional/benchmark/benchmark_error_test.py +++ b/tests/functional/benchmark/benchmark_error_test.py @@ -23,7 +23,9 @@ def test_create_benchmark_error_response(): ) ] model_list = [ - Model(id="model1", name="Model 1", description="Test model", supplier="Test supplier", cost=10, version="v1") + Model( + id="model1", name="Model 1", description="Test model", supplier="Test supplier", cost=10, version="v1" + ) ] url = urljoin(config.BACKEND_URL, "sdk/benchmarks") @@ -33,15 +35,21 @@ def test_create_benchmark_error_response(): mock.post(url, headers=headers, json=error_response, status_code=400) with pytest.raises(Exception) as excinfo: - BenchmarkFactory.create(name=name, dataset_list=dataset_list, model_list=model_list, metric_list=metric_list) + BenchmarkFactory.create( + name=name, dataset_list=dataset_list, model_list=model_list, metric_list=metric_list + ) - assert "Benchmark Creation Error: Status 400 - {'statusCode': 400, 'message': 'Invalid request'}" in str(excinfo.value) + assert "Benchmark Creation Error: Status 400 - {'statusCode': 400, 'message': 'Invalid request'}" in str( + excinfo.value + ) def test_list_normalization_options_error(): metric = MetricFactory.get("66df3e2d6eb56336b6628171") with requests_mock.Mocker() as mock: - model = Model(id="model1", name="Test Model", description="Test model", supplier="Test supplier", cost=10, version="v1") + model = Model( + id="model1", name="Test Model", description="Test model", supplier="Test supplier", cost=10, version="v1" + ) url = urljoin(config.BACKEND_URL, "sdk/benchmarks/normalization-options") headers = {"Authorization": f"Token {config.AIXPLAIN_API_KEY}", "Content-Type": "application/json"} @@ -52,4 +60,6 @@ def test_list_normalization_options_error(): with pytest.raises(Exception) as excinfo: BenchmarkFactory.list_normalization_options(metric, model) - assert "Error listing normalization options: Status 500 - {'message': 'Internal Server Error'}" in str(excinfo.value) + assert "Error listing normalization options: Status 500 - {'message': 'Internal Server Error'}" in str( + excinfo.value + ) diff --git a/tests/functional/model/run_utility_model_test.py b/tests/functional/model/run_utility_model_test.py index a1967be4..05ed1fd6 100644 --- a/tests/functional/model/run_utility_model_test.py +++ b/tests/functional/model/run_utility_model_test.py @@ -4,7 +4,6 @@ import pytest - def test_run_utility_model(): utility_model = None try: diff --git a/tests/functional/v2/test_tool.py b/tests/functional/v2/test_tool.py index d5fbe142..513f66f3 100644 --- a/tests/functional/v2/test_tool.py +++ b/tests/functional/v2/test_tool.py @@ -370,7 +370,6 @@ def test_tool_as_tool_without_actions(client): ) - def test_tool_update_name(client, slack_integration_id, slack_token): """Test updating an existing tool's name via save(). diff --git a/tests/unit/agent/model_tool_test.py b/tests/unit/agent/model_tool_test.py index 5dfc736e..977105f7 100644 --- a/tests/unit/agent/model_tool_test.py +++ b/tests/unit/agent/model_tool_test.py @@ -220,4 +220,7 @@ def test_validate_model_tool_with_model(mocker): def test_validate_model_tool_without_function_or_model(): with pytest.raises(Exception) as exc_info: ModelTool() - assert str(exc_info.value) == "Agent Creation Error: Either function or model must be provided when instantiating a tool." + assert ( + str(exc_info.value) + == "Agent Creation Error: Either function or model must be provided when instantiating a tool." + ) diff --git a/tests/unit/agent/sql_tool_test.py b/tests/unit/agent/sql_tool_test.py index 712bde7d..8ed00e90 100644 --- a/tests/unit/agent/sql_tool_test.py +++ b/tests/unit/agent/sql_tool_test.py @@ -57,7 +57,12 @@ def test_create_sql_tool(mocker, tmp_path): # Test SQLite source type tool = AgentFactory.create_sql_tool( - name="Test SQL", description="Test", source=db_path, source_type="sqlite", schema="test", tables=["test", "test2"] + name="Test SQL", + description="Test", + source=db_path, + source_type="sqlite", + schema="test", + tables=["test", "test2"], ) assert isinstance(tool, SQLTool) assert tool.description == "Test" @@ -186,12 +191,16 @@ def test_sql_tool_validation_errors(tmp_path): # Test non-existent SQLite database with pytest.raises(SQLToolError, match="Database .* does not exist"): - tool = AgentFactory.create_sql_tool(name="Test SQL", description="Test", source="nonexistent.db", source_type="sqlite") + tool = AgentFactory.create_sql_tool( + name="Test SQL", description="Test", source="nonexistent.db", source_type="sqlite" + ) tool.validate() # Test non-existent CSV file with pytest.raises(SQLToolError, match="CSV file .* does not exist"): - tool = AgentFactory.create_sql_tool(name="Test SQL", description="Test", source="nonexistent.csv", source_type="csv") + tool = AgentFactory.create_sql_tool( + name="Test SQL", description="Test", source="nonexistent.csv", source_type="csv" + ) tool.validate() # Test PostgreSQL (not supported) diff --git a/tests/unit/benchmark_test.py b/tests/unit/benchmark_test.py index 40b673da..db6649c4 100644 --- a/tests/unit/benchmark_test.py +++ b/tests/unit/benchmark_test.py @@ -18,4 +18,6 @@ def test_get_benchmark_error(): with pytest.raises(Exception) as excinfo: BenchmarkFactory.get(benchmark_id) - assert "Benchmark GET Error: Status 404 - {'statusCode': 404, 'message': 'Benchmark not found'}" in str(excinfo.value) + assert "Benchmark GET Error: Status 404 - {'statusCode': 404, 'message': 'Benchmark not found'}" in str( + excinfo.value + ) diff --git a/tests/unit/designer_unit_test.py b/tests/unit/designer_unit_test.py index 143d5485..e20d34f0 100644 --- a/tests/unit/designer_unit_test.py +++ b/tests/unit/designer_unit_test.py @@ -842,12 +842,7 @@ class AssetNode(Node, LinkableMixin): ) pipeline.add_link(link1) - link2 = Link( - from_node=node1, - to_node=node2, - from_param="output2", - to_param="input2" - ) + link2 = Link(from_node=node1, to_node=node2, from_param="output2", to_param="input2") pipeline.add_link(link2) serialized = pipeline.serialize() diff --git a/tests/unit/finetune_test.py b/tests/unit/finetune_test.py index 3691bd40..03973331 100644 --- a/tests/unit/finetune_test.py +++ b/tests/unit/finetune_test.py @@ -141,7 +141,9 @@ def test_list_finetunable_models(is_finetunable): print(f"is_finetunable: {is_finetunable}") url = f"{config.BACKEND_URL}/sdk/models/paginate" mock.post(url, headers=FIXED_HEADER, json=list_map) - result = ModelFactory.list(function=Function.TRANSLATION, is_finetunable=is_finetunable, page_number=0, page_size=5) + result = ModelFactory.list( + function=Function.TRANSLATION, is_finetunable=is_finetunable, page_number=0, page_size=5 + ) print(result) assert result["page_total"] == 5 assert result["page_number"] == 0 diff --git a/tests/unit/llm_test.py b/tests/unit/llm_test.py index 5dcf3eb7..9de1b7b2 100644 --- a/tests/unit/llm_test.py +++ b/tests/unit/llm_test.py @@ -34,7 +34,10 @@ 495, "Validation-related error: Please verify the request payload and ensure it is correct. Details: An unspecified error occurred while processing your request.", ), - (501, "Unspecified Server Error (Status 501) Details: An unspecified error occurred while processing your request."), + ( + 501, + "Unspecified Server Error (Status 501) Details: An unspecified error occurred while processing your request.", + ), ], ) def test_run_async_errors(status_code, error_message): diff --git a/tests/unit/pipeline_test.py b/tests/unit/pipeline_test.py index 5f08eec3..f9d8c110 100644 --- a/tests/unit/pipeline_test.py +++ b/tests/unit/pipeline_test.py @@ -123,7 +123,9 @@ def test_get_pipeline_error_response(): with pytest.raises(Exception) as excinfo: PipelineFactory.get(pipeline_id=pipeline_id) - assert "Pipeline GET Error: Failed to retrieve pipeline test-pipeline-id. Status Code: 404" in str(excinfo.value) + assert "Pipeline GET Error: Failed to retrieve pipeline test-pipeline-id. Status Code: 404" in str( + excinfo.value + ) @pytest.fixture diff --git a/tests/unit/script_connection_test.py b/tests/unit/script_connection_test.py index a9f22c24..7ec73a92 100644 --- a/tests/unit/script_connection_test.py +++ b/tests/unit/script_connection_test.py @@ -33,14 +33,11 @@ def _setup_python_sandbox_mocks(mock, connection_id="693026cc427d05e696f3c7db"): "authentication_methods": ["no-auth"], "params": [], "attributes": [ - { - "name": "auth_schemes", - "code": '["NO_AUTH"]' - }, + {"name": "auth_schemes", "code": '["NO_AUTH"]'}, { "name": "NO_AUTH-inputs", - "code": '[{"name":"code","displayName":"Python Code","type":"string","description":"","required":true, "subtype": "file", "fileConfiguration": { "limit": 1, "extensions": ["py"] }}, {"name":"function_name","displayName":"Main Function Name","type":"string","description":"","required":true}]' - } + "code": '[{"name":"code","displayName":"Python Code","type":"string","description":"","required":true, "subtype": "file", "fileConfiguration": { "limit": 1, "extensions": ["py"] }}, {"name":"function_name","displayName":"Main Function Name","type":"string","description":"","required":true}]', + }, ], } mock.get(url, headers=python_sandbox_headers, json=python_sandbox_response) @@ -83,13 +80,7 @@ def _setup_python_sandbox_mocks(mock, connection_id="693026cc427d05e696f3c7db"): list_actions_response = { "status": "SUCCESS", "completed": True, - "data": [ - { - "name": "test_function", - "displayName": "test_function", - "description": "Test function" - } - ], + "data": [{"name": "test_function", "displayName": "test_function", "description": "Test function"}], } mock.post(list_actions_url, headers=list_actions_headers, json=list_actions_response) @@ -102,11 +93,7 @@ def test_create_custom_python_code_tool_with_string_code(): connection_id = _setup_python_sandbox_mocks(mock) code = "def test_function(input_string: str) -> str:\n return 'Hello, world!'\n" - tool = ModelFactory.create_script_connection_tool( - name="Test Tool", - code=code, - description="Test description" - ) + tool = ModelFactory.create_script_connection_tool(name="Test Tool", code=code, description="Test description") assert isinstance(tool, ConnectionTool) assert tool.id == connection_id @@ -126,9 +113,7 @@ def my_function(x: int) -> int: return x * 2 tool = ModelFactory.create_script_connection_tool( - name="Test Tool", - code=my_function, - description="Test description" + name="Test Tool", code=my_function, description="Test description" ) assert isinstance(tool, ConnectionTool) @@ -143,11 +128,7 @@ def test_create_custom_python_code_tool_no_functions(): code = "x = 5\ny = 10\nprint(x + y)" with pytest.raises(Exception) as exc_info: - ModelFactory.create_script_connection_tool( - name="Test Tool", - code=code, - description="Test description" - ) + ModelFactory.create_script_connection_tool(name="Test Tool", code=code, description="Test description") assert "No functions found in the code" in str(exc_info.value) @@ -165,11 +146,7 @@ def function2(y: str) -> str: """ with pytest.raises(Exception) as exc_info: - AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description" - ) + AgentFactory.create_custom_python_code_tool(name="Test Tool", code=code, description="Test description") assert "Multiple functions found in the code" in str(exc_info.value) assert "function1" in str(exc_info.value) @@ -189,10 +166,7 @@ def function2(y: str) -> str: """ tool = AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description", - function_name="function1" + name="Test Tool", code=code, description="Test description", function_name="function1" ) assert isinstance(tool, ConnectionTool) @@ -208,10 +182,7 @@ def test_create_custom_python_code_tool_invalid_function_name(): with pytest.raises(Exception) as exc_info: AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description", - function_name="invalid_function" + name="Test Tool", code=code, description="Test description", function_name="invalid_function" ) assert "Function name invalid_function not found in the code" in str(exc_info.value) @@ -225,11 +196,7 @@ def test_create_custom_python_code_tool_single_function_auto_detection(): code = "def my_single_function(input_string: str) -> str:\n return 'Hello!'\n" - tool = AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description" - ) + tool = AgentFactory.create_custom_python_code_tool(name="Test Tool", code=code, description="Test description") assert isinstance(tool, ConnectionTool) assert tool.id == connection_id @@ -242,29 +209,17 @@ def test_create_custom_python_code_tool_with_different_function_signatures(): # Test with no parameters code1 = "def no_params() -> str:\n return 'test'\n" - tool1 = AgentFactory.create_custom_python_code_tool( - name="Tool 1", - code=code1, - description="No params" - ) + tool1 = AgentFactory.create_custom_python_code_tool(name="Tool 1", code=code1, description="No params") assert isinstance(tool1, ConnectionTool) # Test with multiple parameters code2 = "def multi_params(a: int, b: str, c: float) -> dict:\n return {'a': a, 'b': b, 'c': c}\n" - tool2 = AgentFactory.create_custom_python_code_tool( - name="Tool 2", - code=code2, - description="Multiple params" - ) + tool2 = AgentFactory.create_custom_python_code_tool(name="Tool 2", code=code2, description="Multiple params") assert isinstance(tool2, ConnectionTool) # Test with optional parameters code3 = "def optional_params(x: int, y: int = 10) -> int:\n return x + y\n" - tool3 = AgentFactory.create_custom_python_code_tool( - name="Tool 3", - code=code3, - description="Optional params" - ) + tool3 = AgentFactory.create_custom_python_code_tool(name="Tool 3", code=code3, description="Optional params") assert isinstance(tool3, ConnectionTool) @@ -292,14 +247,11 @@ def test_create_custom_python_code_tool_actions_retrieval(): "authentication_methods": ["no-auth"], "params": [], "attributes": [ - { - "name": "auth_schemes", - "code": '["NO_AUTH"]' - }, + {"name": "auth_schemes", "code": '["NO_AUTH"]'}, { "name": "NO_AUTH-inputs", - "code": '[{"name":"code","displayName":"Python Code","type":"string","description":"","required":true, "subtype": "file", "fileConfiguration": { "limit": 1, "extensions": ["py"] }}, {"name":"function_name","displayName":"Main Function Name","type":"string","description":"","required":true}]' - } + "code": '[{"name":"code","displayName":"Python Code","type":"string","description":"","required":true, "subtype": "file", "fileConfiguration": { "limit": 1, "extensions": ["py"] }}, {"name":"function_name","displayName":"Main Function Name","type":"string","description":"","required":true}]', + }, ], } mock.get(url, headers=python_sandbox_headers, json=python_sandbox_response) @@ -343,26 +295,14 @@ def test_create_custom_python_code_tool_actions_retrieval(): "status": "SUCCESS", "completed": True, "data": [ - { - "name": "action1", - "displayName": "Action 1", - "description": "First action" - }, - { - "name": "action2", - "displayName": "Action 2", - "description": "Second action" - } + {"name": "action1", "displayName": "Action 1", "description": "First action"}, + {"name": "action2", "displayName": "Action 2", "description": "Second action"}, ], } mock.post(list_actions_url, headers=list_actions_headers, json=list_actions_response) code = "def action1(x: int) -> int:\n return x * 2\n" - tool = AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description" - ) + tool = AgentFactory.create_custom_python_code_tool(name="Test Tool", code=code, description="Test description") assert len(tool.actions) == 2 assert tool.actions[0].name == "Action 1" @@ -390,9 +330,7 @@ def process_data(data: Dict[str, List[int]]) -> str: """ tool = AgentFactory.create_custom_python_code_tool( - name="Complex Tool", - code=code, - description="Processes complex data structures" + name="Complex Tool", code=code, description="Processes complex data structures" ) assert isinstance(tool, ConnectionTool) @@ -406,10 +344,7 @@ def test_create_custom_python_code_tool_minimal_parameters(): code = "def simple() -> str:\n return 'simple'\n" - tool = AgentFactory.create_custom_python_code_tool( - code=code, - name="Minimal Tool" - ) + tool = AgentFactory.create_custom_python_code_tool(code=code, name="Minimal Tool") assert isinstance(tool, ConnectionTool) assert tool.id == connection_id @@ -427,9 +362,7 @@ def inner_function(y: int) -> int: """ tool = AgentFactory.create_custom_python_code_tool( - name="Nested Tool", - code=code, - description="Has nested functions" + name="Nested Tool", code=code, description="Has nested functions" ) assert isinstance(tool, ConnectionTool) @@ -452,11 +385,7 @@ def method(self): """ with pytest.raises(Exception) as exc_info: - AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description" - ) + AgentFactory.create_custom_python_code_tool(name="Test Tool", code=code, description="Test description") # Methods inside classes are not detected as top-level functions assert "No functions found in the code" in str(exc_info.value) @@ -483,12 +412,7 @@ def test_create_custom_python_code_tool_error_handling(): "pricing": {"currency": "USD", "value": 0.0}, "authentication_methods": ["no-auth"], "params": [], - "attributes": [ - { - "name": "auth_schemes", - "code": '["NO_AUTH"]' - } - ], + "attributes": [{"name": "auth_schemes", "code": '["NO_AUTH"]'}], } mock.get(url, headers=python_sandbox_headers, json=python_sandbox_response) @@ -498,15 +422,16 @@ def test_create_custom_python_code_tool_error_handling(): "x-api-key": config.TEAM_API_KEY, "Content-Type": "application/json", } - mock.post(run_url, headers=run_headers, json={"status": "FAILED", "error_message": "Connection failed"}, status_code=500) + mock.post( + run_url, + headers=run_headers, + json={"status": "FAILED", "error_message": "Connection failed"}, + status_code=500, + ) code = "def test_function(x: int) -> int:\n return x * 2\n" with pytest.raises(Exception) as exc_info: - AgentFactory.create_custom_python_code_tool( - name="Test Tool", - code=code, - description="Test description" - ) + AgentFactory.create_custom_python_code_tool(name="Test Tool", code=code, description="Test description") assert "Failed to create" in str(exc_info.value) diff --git a/tests/unit/utility_test.py b/tests/unit/utility_test.py index 9aabd831..bd6e7cbb 100644 --- a/tests/unit/utility_test.py +++ b/tests/unit/utility_test.py @@ -27,7 +27,9 @@ def test_utility_model(): assert utility_model.description == "utility_model_test" assert utility_model.code == "utility_model_test" assert utility_model.inputs == [ - UtilityModelInput(name="input_string", description="The input_string input is a text", type=DataType.TEXT) + UtilityModelInput( + name="input_string", description="The input_string input is a text", type=DataType.TEXT + ) ] assert utility_model.output_examples == "output_description" @@ -250,7 +252,10 @@ def main(originCode): with pytest.raises(Exception) as exc_info: parse_code(main) - assert str(exc_info.value) == "Utility Model Error: Input type is required. For instance def main(a: int, b: int) -> int:" + assert ( + str(exc_info.value) + == "Utility Model Error: Input type is required. For instance def main(a: int, b: int) -> int:" + ) # Unsupported input type code = "def main(originCode: list) -> str:\n return originCode" @@ -455,7 +460,9 @@ def test_utility_model_creation_warning(): mock.get(urljoin(config.BACKEND_URL, f"sdk/models/{model_id}"), status_code=200) # Create the utility model and check for warning during creation - with pytest.warns(UserWarning, match="WARNING: Non-deployed utility models .* will expire after 24 hours.*"): + with pytest.warns( + UserWarning, match="WARNING: Non-deployed utility models .* will expire after 24 hours.*" + ): utility_model = ModelFactory.create_utility_model( name="utility_model_test", description="utility_model_test", diff --git a/tests/unit/v2/test_action_inputs_proxy.py b/tests/unit/v2/test_action_inputs_proxy.py index 35aae649..f78cfe2e 100644 --- a/tests/unit/v2/test_action_inputs_proxy.py +++ b/tests/unit/v2/test_action_inputs_proxy.py @@ -380,6 +380,7 @@ def _create_tool_with_inputs(specs): action_obj = Action(name="search", inputs=inputs_obj) from aixplain.v2.actions import Actions + actions = Actions(actions={"search": action_obj}) tool.__dict__["actions"] = actions diff --git a/tests/unit/v2/test_core.py b/tests/unit/v2/test_core.py index b75be5bc..7ed50ab0 100644 --- a/tests/unit/v2/test_core.py +++ b/tests/unit/v2/test_core.py @@ -16,6 +16,7 @@ from aixplain.v2.integration import Integration from aixplain.v2.file import Resource from aixplain.v2.inspector import Inspector +from aixplain.v2.issue import IssueReporter class TestAixplainInitialization: @@ -231,6 +232,7 @@ def test_init_resources_creates_all_resources(self): assert aixplain.Integration is not None assert aixplain.Resource is not None assert aixplain.Inspector is not None + assert aixplain.issue is not None def test_init_resources_sets_context(self): """All resource classes should have context set to Aixplain instance.""" @@ -243,6 +245,7 @@ def test_init_resources_sets_context(self): assert aixplain.Integration.context == aixplain assert aixplain.Resource.context == aixplain assert aixplain.Inspector.context == aixplain + assert aixplain.issue.context == aixplain def test_init_resources_creates_subclasses(self): """Resource classes should be subclasses of base types.""" @@ -253,6 +256,7 @@ def test_init_resources_creates_subclasses(self): assert issubclass(aixplain.Tool, Tool) assert issubclass(aixplain.Utility, Utility) assert issubclass(aixplain.Integration, Integration) + assert isinstance(aixplain.issue, IssueReporter) def test_init_resources_creates_unique_classes(self): """Each instance should have unique resource classes (not base types).""" diff --git a/tests/unit/v2/test_exceptions.py b/tests/unit/v2/test_exceptions.py index 5f717e83..90bd6b03 100644 --- a/tests/unit/v2/test_exceptions.py +++ b/tests/unit/v2/test_exceptions.py @@ -10,6 +10,7 @@ AixplainV2Error, ResourceError, APIError, + AixplainIssueError, ValidationError, TimeoutError, FileUploadError, @@ -221,6 +222,19 @@ def test_can_catch_as_base_error(self): raise ValidationError("Invalid input") +class TestAixplainIssueError: + """Tests for issue reporting errors.""" + + def test_inherits_from_api_error(self): + """AixplainIssueError should preserve APIError semantics.""" + error = AixplainIssueError("Issue reporting failed", status_code=400) + + assert isinstance(error, APIError) + assert isinstance(error, AixplainV2Error) + assert error.status_code == 400 + assert error.message == "Issue reporting failed" + + class TestTimeoutError: """Tests for timeout errors.""" diff --git a/tests/unit/v2/test_issue.py b/tests/unit/v2/test_issue.py new file mode 100644 index 00000000..b65cac1c --- /dev/null +++ b/tests/unit/v2/test_issue.py @@ -0,0 +1,126 @@ +"""Unit tests for the v2 issue reporting client.""" + +from unittest.mock import Mock + +import pytest + +from aixplain.v2.core import Aixplain +from aixplain.v2.exceptions import APIError, AixplainIssueError +from aixplain.v2.issue import IssueSeverity + + +class TestIssueReporter: + def test_report_minimal_submission(self): + aix = Aixplain(api_key="test-key") + aix.client.post = Mock( + return_value={"issue_id": "issue-123", "bug_board_url": "https://bugs.example.com/issue-123"} + ) + + issue_id = aix.issue.report("Pipeline times out on step 3 when using GPT-4o tool") + + assert issue_id == "issue-123" + aix.client.post.assert_called_once_with( + "https://platform-api.aixplain.com/v1/issue", + json={"description": "Pipeline times out on step 3 when using GPT-4o tool"}, + ) + + def test_report_full_submission_payload(self): + aix = Aixplain(api_key="test-key") + aix.client.post = Mock( + return_value={"issue_id": "issue-456", "bug_board_url": "https://bugs.example.com/issue-456"} + ) + + issue_id = aix.issue.report( + "Pipeline times out on step 3 when using GPT-4o tool", + title="GPT-4o pipeline timeout on step 3", + severity=IssueSeverity.SEV2, + tags=["sdk", "timeout"], + sdk_version="1.4.2", + runtime_context={"trace_id": "abc-123", "environment": "production"}, + reporter_email="developer@example.com", + ) + + assert issue_id == "issue-456" + aix.client.post.assert_called_once_with( + "https://platform-api.aixplain.com/v1/issue", + json={ + "description": "Pipeline times out on step 3 when using GPT-4o tool", + "title": "GPT-4o pipeline timeout on step 3", + "severity": "SEV2", + "tags": ["sdk", "timeout"], + "sdk_version": "1.4.2", + "runtime_context": { + "trace_id": "abc-123", + "environment": "production", + }, + "reporter_email": "developer@example.com", + }, + ) + + def test_report_omits_none_fields(self): + aix = Aixplain(api_key="test-key") + aix.client.post = Mock(return_value={"issue_id": "issue-789"}) + + aix.issue.report( + "Agent crashes on tool invocation", + title=None, + severity="SEV1", + sdk_version="1.4.2", + ) + + aix.client.post.assert_called_once_with( + "https://platform-api.aixplain.com/v1/issue", + json={ + "description": "Agent crashes on tool invocation", + "severity": "SEV1", + "sdk_version": "1.4.2", + }, + ) + + @pytest.mark.parametrize( + ("description", "message"), + [(None, "Field 'description' is required.")], + ) + def test_report_validates_description(self, description, message): + aix = Aixplain(api_key="test-key") + + with pytest.raises(AixplainIssueError, match=message): + aix.issue.report(description) + + def test_report_validates_severity(self): + aix = Aixplain(api_key="test-key") + + with pytest.raises(AixplainIssueError, match="severity must be one of: SEV1, SEV2, SEV3, SEV4."): + aix.issue.report("Agent crashes on tool invocation", severity="CRITICAL") + + def test_report_wraps_api_error(self): + aix = Aixplain(api_key="test-key") + aix.client.post = Mock( + side_effect=APIError( + "Field 'description' must not be empty.", + status_code=400, + response_data={"message": "Field 'description' must not be empty."}, + error="VALIDATION_ERROR", + ) + ) + + with pytest.raises(AixplainIssueError) as exc_info: + aix.issue.report(" broken ".strip()) + + assert exc_info.value.message == "Field 'description' must not be empty." + assert exc_info.value.status_code == 400 + assert exc_info.value.response_data == {"message": "Field 'description' must not be empty."} + assert exc_info.value.error == "VALIDATION_ERROR" + + def test_report_rejects_unknown_fields(self): + aix = Aixplain(api_key="test-key") + + with pytest.raises(AixplainIssueError, match="Unsupported issue fields: unknown_field."): + aix.issue.report("Example issue", unknown_field="value") + + def test_report_requires_issue_id_in_response(self): + aix = Aixplain(api_key="test-key") + aix.client.post = Mock(return_value={"bug_board_url": "https://bugs.example.com/issue-123"}) + + with pytest.raises(AixplainIssueError, match="no issue_id was returned"): + aix.issue.report("Pipeline times out on step 3 when using GPT-4o tool") diff --git a/tests/unit/v2/test_model.py b/tests/unit/v2/test_model.py index 6428511f..b494000d 100644 --- a/tests/unit/v2/test_model.py +++ b/tests/unit/v2/test_model.py @@ -11,7 +11,15 @@ from unittest.mock import Mock, patch from aixplain.v2.enums import Function, ResponseStatus -from aixplain.v2.model import Message, Model, ModelResponseStreamer, ModelResult, StreamChunk, Usage, find_function_by_id +from aixplain.v2.model import ( + Message, + Model, + ModelResponseStreamer, + ModelResult, + StreamChunk, + Usage, + find_function_by_id, +) # =============================================================================