diff --git a/.env.example b/.env.example index 31d4df03..c4360d7b 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,12 @@ # GOOGLE_SHEETS_ACCESS_TOKEN= # GOOGLE_SHEETS_TEST_SPREADSHEET_ID= +# -- Grammarly -- +# GRAMMARLY_CLIENT_ID= +# GRAMMARLY_CLIENT_SECRET= + + + # -- Harvest -- # HARVEST_ACCESS_TOKEN= # HARVEST_ACCOUNT_ID= diff --git a/grammarly/config.json b/grammarly/config.json index d304ab6a..4b3276d1 100644 --- a/grammarly/config.json +++ b/grammarly/config.json @@ -1,7 +1,7 @@ { "name": "Grammarly", "display_name": "Grammarly", - "version": "1.0.0", + "version": "2.0.0", "description": "Grammarly integration for writing quality assessment, AI detection, plagiarism checking, and analytics. Includes Writing Score API, Analytics API, AI Detection API, and Plagiarism Detection API.", "entry_point": "grammarly.py", "auth": { @@ -40,7 +40,10 @@ "description": "The text content of the file to analyze" } }, - "required": ["filename", "file_content"] + "required": [ + "filename", + "file_content" + ] }, "output_schema": { "type": "object", @@ -48,17 +51,9 @@ "score_request_id": { "type": "string", "description": "Unique identifier to retrieve the analysis results" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "get_writing_score_results": { @@ -72,7 +67,9 @@ "description": "The score request ID from create_writing_score_request" } }, - "required": ["score_request_id"] + "required": [ + "score_request_id" + ] }, "output_schema": { "type": "object", @@ -80,38 +77,49 @@ "status": { "type": "string", "description": "Status of the scoring request: PENDING, COMPLETED, or FAILED", - "enum": ["PENDING", "COMPLETED", "FAILED"] + "enum": [ + "PENDING", + "COMPLETED", + "FAILED" + ] }, "general_score": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Overall writing quality score (null if status is PENDING or FAILED)" }, "engagement": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Engagement score (null if status is PENDING or FAILED)" }, "correctness": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Correctness score (null if status is PENDING or FAILED)" }, "delivery": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Delivery score (null if status is PENDING or FAILED)" }, "clarity": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Clarity score (null if status is PENDING or FAILED)" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "get_user_analytics": { @@ -139,7 +147,10 @@ "maximum": 400 } }, - "required": ["date_from", "date_to"] + "required": [ + "date_from", + "date_to" + ] }, "output_schema": { "type": "object", @@ -151,7 +162,10 @@ "type": "object", "properties": { "id": { - "type": ["string", "number"], + "type": [ + "string", + "number" + ], "description": "Unique user identifier" }, "name": { @@ -198,17 +212,9 @@ "description": "Number of items in current page" } } - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "analyze_ai_detection": { @@ -226,7 +232,10 @@ "description": "The text content of the file to analyze" } }, - "required": ["filename", "file_content"] + "required": [ + "filename", + "file_content" + ] }, "output_schema": { "type": "object", @@ -234,17 +243,9 @@ "score_request_id": { "type": "string", "description": "Unique identifier to retrieve the analysis results" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "get_ai_detection_results": { @@ -258,7 +259,9 @@ "description": "The score request ID from create_ai_detection_request" } }, - "required": ["score_request_id"] + "required": [ + "score_request_id" + ] }, "output_schema": { "type": "object", @@ -266,30 +269,32 @@ "status": { "type": "string", "description": "Status of the detection request: PENDING, COMPLETED, or FAILED", - "enum": ["PENDING", "COMPLETED", "FAILED"] + "enum": [ + "PENDING", + "COMPLETED", + "FAILED" + ] }, "average_confidence": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Confidence level of the AI detection (0-1 scale, null if status is PENDING or FAILED)" }, "ai_generated_percentage": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Percentage of text that appears to be AI-generated (0-100, null if status is PENDING or FAILED)" }, "updated_at": { "type": "string", "description": "Timestamp when the result was last updated" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "analyze_plagiarism_detection": { @@ -307,7 +312,10 @@ "description": "The text content of the file to analyze" } }, - "required": ["filename", "file_content"] + "required": [ + "filename", + "file_content" + ] }, "output_schema": { "type": "object", @@ -315,17 +323,9 @@ "score_request_id": { "type": "string", "description": "Unique identifier to retrieve the analysis results" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } }, "get_plagiarism_detection_results": { @@ -339,7 +339,9 @@ "description": "The score request ID from create_plagiarism_detection_request" } }, - "required": ["score_request_id"] + "required": [ + "score_request_id" + ] }, "output_schema": { "type": "object", @@ -347,31 +349,33 @@ "status": { "type": "string", "description": "Status of the detection request: PENDING, COMPLETED, or FAILED", - "enum": ["PENDING", "COMPLETED", "FAILED"] + "enum": [ + "PENDING", + "COMPLETED", + "FAILED" + ] }, "originality_score": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Originality score (higher means more original, null if status is PENDING or FAILED)" }, "plagiarism_percentage": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Percentage of text that appears to be plagiarized (0-100, null if status is PENDING or FAILED)" }, "updated_at": { "type": "string", "description": "Timestamp when the result was last updated" - }, - "result": { - "type": "boolean", - "description": "Whether the operation was successful" - }, - "error": { - "type": "string", - "description": "Error message if the action failed" } }, - "required": ["result"] + "required": [] } } } -} +} \ No newline at end of file diff --git a/grammarly/grammarly.py b/grammarly/grammarly.py index b1cf075b..b02e871a 100644 --- a/grammarly/grammarly.py +++ b/grammarly/grammarly.py @@ -1,68 +1,52 @@ +from urllib.parse import urlencode +from yarl import URL from autohive_integrations_sdk import ( Integration, ExecutionContext, ActionHandler, ActionResult, + ActionError, ) from typing import Dict, Any, Optional -import aiohttp - -# Create the integration using the config.json from the same directory as this file grammarly = Integration.load() -# Base URLs for Grammarly API GRAMMARLY_TOKEN_URL = "https://auth.grammarly.com/v4/api/oauth2/token" # nosec B105 GRAMMARLY_WRITING_SCORE_URL = "https://api.grammarly.com/ecosystem/api/v2/scores" GRAMMARLY_ANALYTICS_URL = "https://api.grammarly.com/ecosystem/api/v2/analytics/users" GRAMMARLY_AI_DETECTION_URL = "https://api.grammarly.com/ecosystem/api/v1/ai-detection" GRAMMARLY_PLAGIARISM_URL = "https://api.grammarly.com/ecosystem/api/v1/plagiarism" - -# ---- Helper Functions ---- +GRAMMARLY_SCOPES = ( + "scores-api:read scores-api:write analytics-api:read " + "ai-detection-api:read ai-detection-api:write plagiarism-api:read plagiarism-api:write" +) async def get_access_token(context: ExecutionContext) -> str: - """ - Get OAuth2 access token using client credentials flow. - - Note: In serverless environments (Lambda), tokens are not cached between invocations. - Each invocation requests a fresh token. - - Args: - context: ExecutionContext containing auth credentials - - Returns: - Valid access token - """ - credentials = context.auth.get("credentials", {}) - client_id = credentials.get("client_id", "") - client_secret = credentials.get("client_secret", "") - - # Hardcoded scopes for all API access - scopes = ( - "scores-api:read scores-api:write analytics-api:read " - "ai-detection-api:read ai-detection-api:write plagiarism-api:read plagiarism-api:write" + credentials = context.auth.get("credentials", {}) or {} + client_id = credentials.get("client_id") or context.auth.get("client_id", "") + client_secret = credentials.get("client_secret") or context.auth.get("client_secret", "") + + form_body = urlencode( + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": GRAMMARLY_SCOPES, + } ) - # Request new token using form-encoded data - body = { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": scopes, - } - - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - async with aiohttp.ClientSession() as session: - async with session.post(GRAMMARLY_TOKEN_URL, data=body, headers=headers) as resp: - if resp.status == 200: - token_data = await resp.json() - return token_data.get("access_token") - else: - error_text = await resp.text() - raise Exception(f"Failed to obtain access token: HTTP {resp.status}: {error_text}") + resp = await context.fetch( + GRAMMARLY_TOKEN_URL, + method="POST", + data=form_body, + content_type="application/x-www-form-urlencoded", + ) + token_data = resp.data + if not isinstance(token_data, dict) or "access_token" not in token_data: + raise Exception("Failed to obtain Grammarly access token") + return token_data["access_token"] async def api_request( @@ -71,93 +55,44 @@ async def api_request( url: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """ - Execute an API request to Grammarly API. - - Args: - context: ExecutionContext with auth credentials - method: HTTP method (GET, POST, etc.) - url: Full API endpoint URL - json_data: Optional JSON body for the request - params: Optional query parameters - - Returns: - API response data - """ +) -> Any: access_token = await get_access_token(context) - headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } + resp = await context.fetch(url, method=method, headers=headers, json=json_data, params=params) + return resp.data - async with aiohttp.ClientSession() as session: - async with session.request(method, url, headers=headers, json=json_data, params=params) as resp: - if resp.status in [200, 201, 202]: - return await resp.json() - else: - error_text = await resp.text() - raise Exception(f"API request failed: HTTP {resp.status}: {error_text}") - - -async def upload_file(upload_url: str, file_content: str) -> bool: - """ - Upload a file to a pre-signed URL. - - Args: - upload_url: The pre-signed upload URL - file_content: The content to upload - - Returns: - True if upload was successful - """ - headers = {"Content-Type": "text/plain"} - # Use yarl.URL with encoded=True to prevent aiohttp from modifying the pre-signed URL - # This is critical for S3 pre-signed URLs which have query parameters with signatures - from yarl import URL - - url = URL(upload_url, encoded=True) - - async with aiohttp.ClientSession() as session: - async with session.put(url, data=file_content.encode("utf-8"), headers=headers) as resp: - if resp.status in [200, 201, 204]: - return True - else: - error_text = await resp.text() - raise Exception(f"File upload failed: HTTP {resp.status}: {error_text}") - - -# ---- Writing Score API Actions ---- +async def upload_file(context: ExecutionContext, upload_url: str, file_content: str) -> bool: + await context.fetch( + URL(upload_url, encoded=True), + method="PUT", + data=file_content, + content_type="text/plain", + ) + return True @grammarly.action("analyze_writing_score") class AnalyzeWritingScoreAction(ActionHandler): - """Submit a document for writing quality analysis. Combines request creation and file upload.""" + """Submit a document for writing quality analysis.""" async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: filename = inputs["filename"] file_content = inputs["file_content"] - # Step 1: Create the score request to get upload URL - payload = {"filename": filename} - response = await api_request(context, "POST", GRAMMARLY_WRITING_SCORE_URL, json_data=payload) - + response = await api_request(context, "POST", GRAMMARLY_WRITING_SCORE_URL, json_data={"filename": filename}) score_request_id = response.get("score_request_id") upload_url = response.get("file_upload_url") - # Step 2: Upload the file to the pre-signed URL - await upload_file(upload_url, file_content) - - return ActionResult( - data={"score_request_id": score_request_id, "result": True}, - cost_usd=0.0, - ) + await upload_file(context, upload_url, file_content) + return ActionResult(data={"score_request_id": score_request_id}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @grammarly.action("get_writing_score_results") @@ -171,9 +106,8 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await api_request(context, "GET", url) - result = {"status": response.get("status"), "result": True} + result: Dict[str, Any] = {"status": response.get("status")} - # Add score data if available if response.get("status") == "COMPLETED" and "score" in response: score_data = response["score"] result["general_score"] = score_data.get("generalScore") @@ -183,12 +117,8 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): result["clarity"] = score_data.get("clarity") return ActionResult(data=result, cost_usd=0.0) - except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) - - -# ---- Analytics API Actions ---- + return ActionError(message=str(e)) @grammarly.action("get_user_analytics") @@ -197,61 +127,41 @@ class GetUserAnalyticsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - params = {"date_from": inputs["date_from"], "date_to": inputs["date_to"]} + params: Dict[str, Any] = {"date_from": inputs["date_from"], "date_to": inputs["date_to"]} - if "cursor" in inputs and inputs["cursor"]: + if inputs.get("cursor"): params["cursor"] = inputs["cursor"] - - if "limit" in inputs and inputs["limit"]: + if inputs.get("limit"): params["limit"] = inputs["limit"] response = await api_request(context, "GET", GRAMMARLY_ANALYTICS_URL, params=params) return ActionResult( - data={ - "data": response.get("data", []), - "paging": response.get("paging", {}), - "result": True, - }, + data={"data": response.get("data", []), "paging": response.get("paging", {})}, cost_usd=0.0, ) - except Exception as e: - return ActionResult( - data={"data": [], "paging": {}, "result": False, "error": str(e)}, - cost_usd=0.0, - ) - - -# ---- AI Detection API Actions ---- + return ActionError(message=str(e)) @grammarly.action("analyze_ai_detection") class AnalyzeAIDetectionAction(ActionHandler): - """Submit a document for AI content detection analysis. Combines request creation and file upload.""" + """Submit a document for AI content detection analysis.""" async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: filename = inputs["filename"] file_content = inputs["file_content"] - # Step 1: Create the detection request to get upload URL - payload = {"filename": filename} - response = await api_request(context, "POST", GRAMMARLY_AI_DETECTION_URL, json_data=payload) - + response = await api_request(context, "POST", GRAMMARLY_AI_DETECTION_URL, json_data={"filename": filename}) score_request_id = response.get("score_request_id") upload_url = response.get("file_upload_url") - # Step 2: Upload the file to the pre-signed URL - await upload_file(upload_url, file_content) - - return ActionResult( - data={"score_request_id": score_request_id, "result": True}, - cost_usd=0.0, - ) + await upload_file(context, upload_url, file_content) + return ActionResult(data={"score_request_id": score_request_id}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @grammarly.action("get_ai_detection_results") @@ -265,9 +175,8 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await api_request(context, "GET", url) - result = {"status": response.get("status"), "result": True} + result: Dict[str, Any] = {"status": response.get("status")} - # Add score data if available if response.get("status") == "COMPLETED" and "score" in response: score_data = response["score"] result["average_confidence"] = score_data.get("average_confidence") @@ -275,40 +184,28 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): result["updated_at"] = response.get("updated_at") return ActionResult(data=result, cost_usd=0.0) - except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) - - -# ---- Plagiarism Detection API Actions ---- + return ActionError(message=str(e)) @grammarly.action("analyze_plagiarism_detection") class AnalyzePlagiarismDetectionAction(ActionHandler): - """Submit a document for plagiarism detection analysis. Combines request creation and file upload.""" + """Submit a document for plagiarism detection analysis.""" async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: filename = inputs["filename"] file_content = inputs["file_content"] - # Step 1: Create the detection request to get upload URL - payload = {"filename": filename} - response = await api_request(context, "POST", GRAMMARLY_PLAGIARISM_URL, json_data=payload) - + response = await api_request(context, "POST", GRAMMARLY_PLAGIARISM_URL, json_data={"filename": filename}) score_request_id = response.get("score_request_id") upload_url = response.get("file_upload_url") - # Step 2: Upload the file to the pre-signed URL - await upload_file(upload_url, file_content) - - return ActionResult( - data={"score_request_id": score_request_id, "result": True}, - cost_usd=0.0, - ) + await upload_file(context, upload_url, file_content) + return ActionResult(data={"score_request_id": score_request_id}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @grammarly.action("get_plagiarism_detection_results") @@ -322,18 +219,15 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await api_request(context, "GET", url) - result = {"status": response.get("status"), "result": True} + result: Dict[str, Any] = {"status": response.get("status")} - # Add score data if available if response.get("status") == "COMPLETED" and "score" in response: score_data = response["score"] - # Calculate plagiarism percentage from originality score originality = score_data.get("originality_score", 100) result["originality_score"] = originality result["plagiarism_percentage"] = max(0, 100 - originality) result["updated_at"] = response.get("updated_at") return ActionResult(data=result, cost_usd=0.0) - except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) diff --git a/grammarly/requirements.txt b/grammarly/requirements.txt index b56fee2e..7c922dbc 100644 --- a/grammarly/requirements.txt +++ b/grammarly/requirements.txt @@ -1 +1,2 @@ -autohive-integrations-sdk~=1.0.2 +autohive-integrations-sdk~=2.0.0 +yarl>=1.9 diff --git a/grammarly/tests/conftest.py b/grammarly/tests/conftest.py new file mode 100644 index 00000000..0f8958cc --- /dev/null +++ b/grammarly/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from autohive_integrations_sdk import ExecutionContext + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(spec=ExecutionContext) + ctx.fetch = AsyncMock() + ctx.auth = { + "client_id": "test_client_id", + "client_secret": "test_client_secret", # nosec B105 + } + return ctx diff --git a/grammarly/tests/context.py b/grammarly/tests/context.py deleted file mode 100644 index 4e97343e..00000000 --- a/grammarly/tests/context.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -import sys -import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies"))) diff --git a/grammarly/tests/test_grammarly.py b/grammarly/tests/test_grammarly.py deleted file mode 100644 index 1b68c34d..00000000 --- a/grammarly/tests/test_grammarly.py +++ /dev/null @@ -1,285 +0,0 @@ -# Testbed for Grammarly integration -import asyncio -from context import grammarly -from autohive_integrations_sdk import ExecutionContext - -# Configuration - Replace these placeholder values with actual values for testing -CLIENT_ID = "your_client_id_here" # Replace with actual Grammarly Client ID -CLIENT_SECRET = "your_client_secret_here" # Replace with actual Grammarly Client Secret # nosec B105 - -# Sample test content -SAMPLE_TEXT = """ -This is a sample document for testing Grammarly's API capabilities. -The text should be at least 30 words to meet the minimum requirement for scoring. -This paragraph demonstrates basic writing that can be analyzed for quality, -engagement, correctness, delivery, and clarity metrics. -""" - - -async def test_writing_score_workflow(): - """Test complete writing score workflow: submit document and get results.""" - print("=== TESTING WRITING SCORE API WORKFLOW ===") - - auth = { - "auth_type": "custom", - "credentials": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - } - - try: - # Step 1: Submit document for analysis - print("1. Submitting document for writing score analysis...") - analyze_inputs = {"filename": "test_document.txt", "file_content": SAMPLE_TEXT} - - async with ExecutionContext(auth=auth) as context: - analyze_result = await grammarly.execute_action("analyze_writing_score", analyze_inputs, context) - - if analyze_result.get("result"): - score_request_id = analyze_result.get("score_request_id") - print(f" ✓ Document submitted successfully (ID: {score_request_id})") - else: - print(f" ✗ Failed to submit document: {analyze_result.get('error')}") - return - - # Step 2: Get results (may need to wait for processing) - print("2. Retrieving writing score results...") - print(" (Note: Processing may take a few moments)") - - for attempt in range(5): - await asyncio.sleep(2) # Wait 2 seconds between checks - - results_inputs = {"score_request_id": score_request_id} - - async with ExecutionContext(auth=auth) as context: - results = await grammarly.execute_action("get_writing_score_results", results_inputs, context) - - if results.get("result"): - status = results.get("status") - print(f" Status: {status} (Attempt {attempt + 1}/5)") - - if status == "COMPLETED": - print(" ✓ Scoring completed!") - print(f" General Score: {results.get('general_score')}") - print(f" Engagement: {results.get('engagement')}") - print(f" Correctness: {results.get('correctness')}") - print(f" Delivery: {results.get('delivery')}") - print(f" Clarity: {results.get('clarity')}") - break - elif status == "FAILED": - print(" ✗ Scoring failed") - break - else: - print(f" ✗ Error: {results.get('error')}") - break - - print("=== WRITING SCORE WORKFLOW TEST COMPLETED ===\n") - - except Exception as e: - print(f"Error in writing score workflow: {e}\n") - - -async def test_ai_detection_workflow(): - """Test complete AI detection workflow: submit document and get results.""" - print("=== TESTING AI DETECTION API WORKFLOW ===") - - auth = { - "auth_type": "custom", - "credentials": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - } - - try: - # Step 1: Submit document for AI detection - print("1. Submitting document for AI detection analysis...") - analyze_inputs = { - "filename": "ai_test_document.txt", - "file_content": SAMPLE_TEXT, - } - - async with ExecutionContext(auth=auth) as context: - analyze_result = await grammarly.execute_action("analyze_ai_detection", analyze_inputs, context) - - if analyze_result.get("result"): - score_request_id = analyze_result.get("score_request_id") - print(f" ✓ Document submitted successfully (ID: {score_request_id})") - else: - print(f" ✗ Failed to submit document: {analyze_result.get('error')}") - return - - # Step 2: Get results - print("2. Retrieving AI detection results...") - print(" (Note: Processing may take a few moments)") - - for attempt in range(5): - await asyncio.sleep(2) - - results_inputs = {"score_request_id": score_request_id} - - async with ExecutionContext(auth=auth) as context: - results = await grammarly.execute_action("get_ai_detection_results", results_inputs, context) - - if results.get("result"): - status = results.get("status") - print(f" Status: {status} (Attempt {attempt + 1}/5)") - - if status == "COMPLETED": - print(" ✓ AI Detection completed!") - print(f" Average Confidence: {results.get('average_confidence')}") - print(f" AI Generated Percentage: {results.get('ai_generated_percentage')}%") - break - elif status == "FAILED": - print(" ✗ AI Detection failed") - break - else: - print(f" ✗ Error: {results.get('error')}") - break - - print("=== AI DETECTION WORKFLOW TEST COMPLETED ===\n") - - except Exception as e: - print(f"Error in AI detection workflow: {e}\n") - - -async def test_plagiarism_detection_workflow(): - """Test complete plagiarism detection workflow: submit document and get results.""" - print("=== TESTING PLAGIARISM DETECTION API WORKFLOW ===") - - auth = { - "auth_type": "custom", - "credentials": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - } - - try: - # Step 1: Submit document for plagiarism detection - print("1. Submitting document for plagiarism detection analysis...") - analyze_inputs = { - "filename": "plagiarism_test_document.txt", - "file_content": SAMPLE_TEXT, - } - - async with ExecutionContext(auth=auth) as context: - analyze_result = await grammarly.execute_action("analyze_plagiarism_detection", analyze_inputs, context) - - if analyze_result.get("result"): - score_request_id = analyze_result.get("score_request_id") - print(f" ✓ Document submitted successfully (ID: {score_request_id})") - else: - print(f" ✗ Failed to submit document: {analyze_result.get('error')}") - return - - # Step 2: Get results - print("2. Retrieving plagiarism detection results...") - print(" (Note: Processing may take a few moments)") - - for attempt in range(5): - await asyncio.sleep(2) - - results_inputs = {"score_request_id": score_request_id} - - async with ExecutionContext(auth=auth) as context: - results = await grammarly.execute_action("get_plagiarism_detection_results", results_inputs, context) - - if results.get("result"): - status = results.get("status") - print(f" Status: {status} (Attempt {attempt + 1}/5)") - - if status == "COMPLETED": - print(" ✓ Plagiarism Detection completed!") - print(f" Originality Score: {results.get('originality_score')}") - print(f" Plagiarism Percentage: {results.get('plagiarism_percentage')}%") - break - elif status == "FAILED": - print(" ✗ Plagiarism Detection failed") - break - else: - print(f" ✗ Error: {results.get('error')}") - break - - print("=== PLAGIARISM DETECTION WORKFLOW TEST COMPLETED ===\n") - - except Exception as e: - print(f"Error in plagiarism detection workflow: {e}\n") - - -async def test_analytics_api(): - """Test Analytics API for user statistics.""" - print("=== TESTING ANALYTICS API ===") - - auth = { - "auth_type": "custom", - "credentials": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - } - - try: - # Get current date and calculate date range - from datetime import datetime, timedelta - - date_to = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d") - date_from = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") - - print(f"Retrieving analytics from {date_from} to {date_to}...") - - inputs = {"date_from": date_from, "date_to": date_to, "limit": 10} - - async with ExecutionContext(auth=auth) as context: - result = await grammarly.execute_action("get_user_analytics", inputs, context) - - if result.get("result"): - data = result.get("data", []) - paging = result.get("paging", {}) - - print(" ✓ Analytics retrieved successfully") - print(f" Users returned: {len(data)}") - print(f" Has more pages: {paging.get('has_more', False)}") - - # Show first few users - for i, user in enumerate(data[:3]): - print(f"\n User {i + 1}:") - print(f" Name: {user.get('name')}") - print(f" Email: {user.get('email')}") - print(f" Days Active: {user.get('days_active')}") - print(f" Sessions: {user.get('sessions_count')}") - print(f" Sessions Improved: {user.get('sessions_improved_percent')}%") - print(f" AI Prompts: {user.get('prompt_count')}") - else: - print(f" ✗ Failed to retrieve analytics: {result.get('error')}") - - print("\n=== ANALYTICS API TEST COMPLETED ===\n") - - except Exception as e: - print(f"Error in analytics test: {e}\n") - - -async def main(): - """Run all Grammarly integration tests.""" - print("=" * 60) - print("GRAMMARLY INTEGRATION TESTS") - print("=" * 60) - print() - - # Check if credentials are set - if CLIENT_ID == "your_client_id_here" or CLIENT_SECRET == "your_client_secret_here": # nosec B105 - print("⚠ WARNING: Please set CLIENT_ID and CLIENT_SECRET before running tests!") - print("Edit the test_grammarly.py file and replace the placeholder values.\n") - return - - # Run tests - print("Starting test suite...\n") - - # Test Writing Score API - await test_writing_score_workflow() - - # Test AI Detection API - await test_ai_detection_workflow() - - # Test Plagiarism Detection API - await test_plagiarism_detection_workflow() - - # Test Analytics API - await test_analytics_api() - - print("=" * 60) - print("ALL TESTS COMPLETED") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/grammarly/tests/test_grammarly_integration.py b/grammarly/tests/test_grammarly_integration.py new file mode 100644 index 00000000..4a54b580 --- /dev/null +++ b/grammarly/tests/test_grammarly_integration.py @@ -0,0 +1,185 @@ +""" +End-to-end integration tests for the Grammarly integration. + +Requires credentials set in environment variables or a .env file at the repo root: + GRAMMARLY_CLIENT_ID -- OAuth2 Client ID from the Grammarly developer portal + GRAMMARLY_CLIENT_SECRET -- OAuth2 Client Secret + +Run safely (all tests are read-like submit-and-poll, no destructive data): + pytest grammarly/tests/test_grammarly_integration.py -m integration + +Never runs in CI -- the default pytest marker filter (-m unit) excludes these, +and the file naming (test_*_integration.py) is not matched by python_files. +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +import aiohttp +import pytest +from datetime import date, timedelta +from autohive_integrations_sdk import FetchResponse, HTTPError, RateLimitError, ResultType +from grammarly.grammarly import grammarly + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.getenv("GRAMMARLY_CLIENT_ID") or not os.getenv("GRAMMARLY_CLIENT_SECRET"), + reason="GRAMMARLY_CLIENT_ID and GRAMMARLY_CLIENT_SECRET required", + ), +] + +GRAMMARLY_CLIENT_ID = os.getenv("GRAMMARLY_CLIENT_ID", "") +GRAMMARLY_CLIENT_SECRET = os.getenv("GRAMMARLY_CLIENT_SECRET", "") + +SAMPLE_TEXT = ( + "The quick brown fox jumps over the lazy dog. " + "This is a sample text for writing quality analysis. " + "It contains multiple sentences to meet the minimum word count requirement." +) + + +@pytest.fixture +def live_context(make_context): + async def real_fetch( + url, *, method="GET", params=None, headers=None, json=None, data=None, content_type=None, **kwargs + ): + req_headers = dict(headers or {}) + if content_type: + req_headers["Content-Type"] = content_type + async with aiohttp.ClientSession() as session: + async with session.request( + method, + url, + params=params, + json=json, + data=data, + headers=req_headers, + ) as resp: + try: + resp_data = await resp.json(content_type=None) + except Exception: + resp_data = await resp.text() + + if resp.status == 429: + retry_after = int(resp.headers.get("Retry-After", 60)) + raise RateLimitError(retry_after, resp.status, str(resp_data), resp_data) + if resp.status < 200 or resp.status >= 300: + raise HTTPError(resp.status, str(resp_data), resp_data) + + return FetchResponse(status=resp.status, headers=dict(resp.headers), data=resp_data) + + ctx = make_context( + auth={"credentials": {"client_id": GRAMMARLY_CLIENT_ID, "client_secret": GRAMMARLY_CLIENT_SECRET}} + ) + ctx.fetch.side_effect = real_fetch + return ctx + + +@pytest.mark.asyncio +async def test_analyze_writing_score(live_context): + result = await grammarly.execute_action( + "analyze_writing_score", + {"filename": "test.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "score_request_id" in result.result.data + assert result.result.data["score_request_id"] + + +@pytest.mark.asyncio +async def test_get_writing_score_results(live_context): + submit = await grammarly.execute_action( + "analyze_writing_score", + {"filename": "test.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert submit.type == ResultType.ACTION, submit.result.message + score_request_id = submit.result.data["score_request_id"] + + result = await grammarly.execute_action( + "get_writing_score_results", + {"score_request_id": score_request_id}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "status" in result.result.data + + +@pytest.mark.asyncio +async def test_get_user_analytics(live_context): + date_to = date.today() - timedelta(days=2) + date_from = date_to - timedelta(days=30) + result = await grammarly.execute_action( + "get_user_analytics", + {"date_from": date_from.strftime("%Y-%m-%d"), "date_to": date_to.strftime("%Y-%m-%d")}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "data" in result.result.data + assert "paging" in result.result.data + + +@pytest.mark.asyncio +async def test_analyze_ai_detection(live_context): + result = await grammarly.execute_action( + "analyze_ai_detection", + {"filename": "essay.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "score_request_id" in result.result.data + assert result.result.data["score_request_id"] + + +@pytest.mark.asyncio +async def test_get_ai_detection_results(live_context): + submit = await grammarly.execute_action( + "analyze_ai_detection", + {"filename": "essay.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert submit.type == ResultType.ACTION, submit.result.message + score_request_id = submit.result.data["score_request_id"] + + result = await grammarly.execute_action( + "get_ai_detection_results", + {"score_request_id": score_request_id}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "status" in result.result.data + + +@pytest.mark.asyncio +async def test_analyze_plagiarism_detection(live_context): + result = await grammarly.execute_action( + "analyze_plagiarism_detection", + {"filename": "paper.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "score_request_id" in result.result.data + assert result.result.data["score_request_id"] + + +@pytest.mark.asyncio +async def test_get_plagiarism_detection_results(live_context): + submit = await grammarly.execute_action( + "analyze_plagiarism_detection", + {"filename": "paper.txt", "file_content": SAMPLE_TEXT}, + live_context, + ) + assert submit.type == ResultType.ACTION, submit.result.message + score_request_id = submit.result.data["score_request_id"] + + result = await grammarly.execute_action( + "get_plagiarism_detection_results", + {"score_request_id": score_request_id}, + live_context, + ) + assert result.type == ResultType.ACTION, result.result.message + assert "status" in result.result.data diff --git a/grammarly/tests/test_grammarly_unit.py b/grammarly/tests/test_grammarly_unit.py new file mode 100644 index 00000000..36a38827 --- /dev/null +++ b/grammarly/tests/test_grammarly_unit.py @@ -0,0 +1,230 @@ +import pytest +from unittest.mock import AsyncMock +from autohive_integrations_sdk import FetchResponse, ResultType +from grammarly.grammarly import grammarly + +pytestmark = pytest.mark.unit + +TOKEN_RESPONSE = FetchResponse(status=200, headers={}, data={"access_token": "test_token"}) # nosec B105 +UPLOAD_RESPONSE = FetchResponse(status=200, headers={}, data={}) + + +def _make_fetch(responses): + """Return an AsyncMock that returns responses in sequence.""" + mock = AsyncMock(side_effect=responses) + return mock + + +@pytest.mark.asyncio +async def test_analyze_writing_score(mock_context): + score_resp = FetchResponse( + status=200, headers={}, data={"score_request_id": "req-123", "file_upload_url": "https://s3.example.com/upload"} + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, score_resp, UPLOAD_RESPONSE]) + result = await grammarly.execute_action( + "analyze_writing_score", + {"filename": "doc.txt", "file_content": "Hello world, this is a test document."}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["score_request_id"] == "req-123" + + +@pytest.mark.asyncio +async def test_analyze_writing_score_error(mock_context): + mock_context.fetch = AsyncMock(side_effect=Exception("Auth failed")) + result = await grammarly.execute_action( + "analyze_writing_score", + {"filename": "doc.txt", "file_content": "test content"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Auth failed" in result.result.message + + +@pytest.mark.asyncio +async def test_get_writing_score_results_pending(mock_context): + score_resp = FetchResponse(status=200, headers={}, data={"status": "PENDING"}) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, score_resp]) + result = await grammarly.execute_action( + "get_writing_score_results", + {"score_request_id": "req-123"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["status"] == "PENDING" + + +@pytest.mark.asyncio +async def test_get_writing_score_results_completed(mock_context): + score_resp = FetchResponse( + status=200, + headers={}, + data={ + "status": "COMPLETED", + "score": { + "generalScore": 85, + "engagement": 80, + "correctness": 90, + "delivery": 82, + "clarity": 88, + }, + }, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, score_resp]) + result = await grammarly.execute_action( + "get_writing_score_results", + {"score_request_id": "req-123"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["general_score"] == 85 + assert result.result.data["status"] == "COMPLETED" + + +@pytest.mark.asyncio +async def test_get_writing_score_results_error(mock_context): + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, Exception("Not found")]) + result = await grammarly.execute_action( + "get_writing_score_results", + {"score_request_id": "bad-id"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Not found" in result.result.message + + +@pytest.mark.asyncio +async def test_get_user_analytics(mock_context): + analytics_resp = FetchResponse( + status=200, + headers={}, + data={ + "data": [{"id": "u1", "email": "user@example.com", "days_active": 10}], + "paging": {"has_more": False}, + }, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, analytics_resp]) + result = await grammarly.execute_action( + "get_user_analytics", + {"date_from": "2024-01-01", "date_to": "2024-01-31"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert len(result.result.data["data"]) == 1 + assert "paging" in result.result.data + + +@pytest.mark.asyncio +async def test_get_user_analytics_error(mock_context): + mock_context.fetch = AsyncMock(side_effect=Exception("Rate limit exceeded")) + result = await grammarly.execute_action( + "get_user_analytics", + {"date_from": "2024-01-01", "date_to": "2024-01-31"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Rate limit" in result.result.message + + +@pytest.mark.asyncio +async def test_analyze_ai_detection(mock_context): + ai_resp = FetchResponse( + status=200, + headers={}, + data={"score_request_id": "ai-req-456", "file_upload_url": "https://s3.example.com/upload"}, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, ai_resp, UPLOAD_RESPONSE]) + result = await grammarly.execute_action( + "analyze_ai_detection", + {"filename": "essay.txt", "file_content": "This essay was written by a human."}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["score_request_id"] == "ai-req-456" + + +@pytest.mark.asyncio +async def test_get_ai_detection_results_completed(mock_context): + ai_resp = FetchResponse( + status=200, + headers={}, + data={ + "status": "COMPLETED", + "updated_at": "2024-01-15T10:00:00Z", + "score": {"average_confidence": 0.12, "ai_generated_percentage": 8.5}, + }, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, ai_resp]) + result = await grammarly.execute_action( + "get_ai_detection_results", + {"score_request_id": "ai-req-456"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["average_confidence"] == 0.12 + assert result.result.data["ai_generated_percentage"] == 8.5 + + +@pytest.mark.asyncio +async def test_get_ai_detection_results_pending(mock_context): + ai_resp = FetchResponse(status=200, headers={}, data={"status": "PENDING"}) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, ai_resp]) + result = await grammarly.execute_action( + "get_ai_detection_results", + {"score_request_id": "ai-req-456"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["status"] == "PENDING" + + +@pytest.mark.asyncio +async def test_analyze_plagiarism_detection(mock_context): + plag_resp = FetchResponse( + status=200, + headers={}, + data={"score_request_id": "plag-req-789", "file_upload_url": "https://s3.example.com/upload"}, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, plag_resp, UPLOAD_RESPONSE]) + result = await grammarly.execute_action( + "analyze_plagiarism_detection", + {"filename": "paper.txt", "file_content": "Original research content here."}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["score_request_id"] == "plag-req-789" + + +@pytest.mark.asyncio +async def test_get_plagiarism_detection_results_completed(mock_context): + plag_resp = FetchResponse( + status=200, + headers={}, + data={ + "status": "COMPLETED", + "updated_at": "2024-01-15T10:00:00Z", + "score": {"originality_score": 95}, + }, + ) + mock_context.fetch = AsyncMock(side_effect=[TOKEN_RESPONSE, plag_resp]) + result = await grammarly.execute_action( + "get_plagiarism_detection_results", + {"score_request_id": "plag-req-789"}, + mock_context, + ) + assert result.type != ResultType.ACTION_ERROR + assert result.result.data["originality_score"] == 95 + assert result.result.data["plagiarism_percentage"] == 5 + + +@pytest.mark.asyncio +async def test_get_plagiarism_detection_results_error(mock_context): + mock_context.fetch = AsyncMock(side_effect=Exception("Request timeout")) + result = await grammarly.execute_action( + "get_plagiarism_detection_results", + {"score_request_id": "bad-id"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "timeout" in result.result.message