#!/usr/bin/env python3
"""Clean up orphaned test resources from Google Workspace.
This script finds and deletes resources created by tests that match known
test patterns. It's designed to be run manually or on a schedule to clean
up resources that weren't cleaned up during test runs.
Usage:
# Preview what would be deleted (dry run - default)
python scripts/cleanup_test_resources.py
# Actually delete resources
python scripts/cleanup_test_resources.py --execute
# Clean specific resource types
python scripts/cleanup_test_resources.py --execute --calendar --gmail-filters
# Use custom email
python scripts/cleanup_test_resources.py --email user@example.com
Environment Variables:
TEST_EMAIL_ADDRESS: Google account email to use for cleanup
MCP_SERVER_URL: MCP server URL (default: http://localhost:8002/mcp)
"""
import argparse
import asyncio
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta
# Add project root to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
# Test resource patterns - resources matching these are candidates for cleanup
TEST_PATTERNS = {
"calendar_events": [
r"^Test Event",
r"^All Day Test Event",
r"^Meeting with Team",
r"^Event with Attachments",
r"Bulk Test Event",
r"^Legacy Mode Test Event",
r"^MCP Test",
r"^π", # Emoji prefixes used in bulk tests
r"^π",
r"^π―",
],
"gmail_filters": [
r"@starbucks-test-",
r"@starbucks-retro-",
r"@starbucks-complex-",
r"@example-perf-",
r"@starbucks-scenario-",
r"-test-\d{10,}", # timestamp patterns
],
"gmail_labels": [
r"^Test Label",
r"^MCP Test",
],
"drive_files": [
r"^Test Document",
r"^Test Form",
r"^Test Presentation",
r"^Shared Test Presentation",
r"^Test Album",
],
}
@dataclass
class CleanupResult:
"""Results from cleanup operation."""
resource_type: str
found: int = 0
deleted: int = 0
errors: list[str] = field(default_factory=list)
items: list[dict] = field(default_factory=list)
async def create_cleanup_client(email: str):
"""Create an MCP client for cleanup operations."""
from tests.client.base_test_config import create_test_client
return await create_test_client(email)
def matches_test_pattern(text: str | None, patterns: list[str]) -> bool:
"""Check if text matches any of the test patterns."""
if not text:
return False
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
def parse_mcp_response(response) -> dict | list | str:
"""Parse MCP tool response into usable data.
Handles JSON responses from MCP tools. Falls back to ast.literal_eval
for Python repr format, then returns raw string if parsing fails.
"""
import json
if not response or not response.content:
return {}
content = response.content[0]
text = content.text if hasattr(content, "text") else str(content)
# Try to parse as JSON
try:
return json.loads(text)
except json.JSONDecodeError:
# If not JSON, it might be a Python repr - try ast.literal_eval
import ast
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError):
return text
async def find_test_calendar_events(client, email: str) -> CleanupResult:
"""Find calendar events matching test patterns."""
result = CleanupResult(resource_type="calendar_events")
try:
# Search for events in the past 90 days and next 90 days
time_min = (datetime.utcnow() - timedelta(days=90)).isoformat() + "Z"
time_max = (datetime.utcnow() + timedelta(days=90)).isoformat() + "Z"
response = await client.call_tool(
"list_events",
{
"user_google_email": email,
"time_min": time_min,
"time_max": time_max,
"max_results": 500,
},
)
data = parse_mcp_response(response)
if isinstance(data, dict):
# EventListResponse has "events" field
events = data.get("events", data.get("items", []))
if isinstance(events, list):
for event in events:
if not isinstance(event, dict):
continue
summary = event.get("summary", "")
if matches_test_pattern(summary, TEST_PATTERNS["calendar_events"]):
# Handle both nested and flat start time formats
start = event.get("start")
if isinstance(start, dict):
start_time = start.get("dateTime", start.get("date"))
else:
start_time = start # Already a string
result.items.append(
{
"id": event.get("id"),
"summary": summary,
"start": start_time,
}
)
result.found = len(result.items)
except Exception as e:
result.errors.append(f"Error listing calendar events: {e}")
return result
async def find_test_gmail_filters(client, email: str) -> CleanupResult:
"""Find Gmail filters matching test patterns."""
result = CleanupResult(resource_type="gmail_filters")
try:
response = await client.call_tool(
"list_gmail_filters",
{"user_google_email": email},
)
data = parse_mcp_response(response)
if isinstance(data, dict):
filters = data.get("filters", data.get("items", []))
if isinstance(filters, list):
for f in filters:
criteria = f.get("criteria", {})
from_addr = criteria.get("from", "")
if matches_test_pattern(from_addr, TEST_PATTERNS["gmail_filters"]):
result.items.append(
{
"id": f.get("id"),
"criteria": criteria,
}
)
result.found = len(result.items)
except Exception as e:
result.errors.append(f"Error listing Gmail filters: {e}")
return result
async def find_test_gmail_labels(client, email: str) -> CleanupResult:
"""Find Gmail labels matching test patterns."""
result = CleanupResult(resource_type="gmail_labels")
try:
response = await client.call_tool(
"list_gmail_labels",
{"user_google_email": email},
)
data = parse_mcp_response(response)
if isinstance(data, dict):
labels = data.get("labels", data.get("items", []))
if isinstance(labels, list):
for label in labels:
name = label.get("name", "")
# Only match user-created labels (not system labels)
label_type = label.get("type", "user")
if label_type == "user" and matches_test_pattern(
name, TEST_PATTERNS["gmail_labels"]
):
result.items.append(
{
"id": label.get("id"),
"name": name,
}
)
result.found = len(result.items)
except Exception as e:
result.errors.append(f"Error listing Gmail labels: {e}")
return result
async def find_test_drive_files(client, email: str) -> CleanupResult:
"""Find Drive files matching test patterns."""
result = CleanupResult(resource_type="drive_files")
try:
# Search for files with test-related names using search_drive_files
response = await client.call_tool(
"search_drive_files",
{
"user_google_email": email,
"query": "name contains 'Test' or name contains 'MCP'",
"page_size": 100,
},
)
data = parse_mcp_response(response)
if isinstance(data, dict):
# DriveSearchResponse has "results" field
files = data.get("results", data.get("files", data.get("items", [])))
if isinstance(files, list):
for f in files:
name = f.get("name", "")
if matches_test_pattern(name, TEST_PATTERNS["drive_files"]):
result.items.append(
{
"id": f.get("id"),
"name": name,
"mimeType": f.get("mimeType"),
}
)
result.found = len(result.items)
except Exception as e:
result.errors.append(f"Error listing Drive files: {e}")
return result
async def delete_calendar_events(
client, email: str, event_ids: list[str]
) -> tuple[int, list[str]]:
"""Delete calendar events by ID."""
deleted = 0
errors = []
for event_id in event_ids:
try:
await client.call_tool(
"delete_event",
{"user_google_email": email, "event_id": event_id},
)
deleted += 1
except Exception as e:
errors.append(f"Failed to delete event {event_id}: {e}")
return deleted, errors
async def delete_gmail_filters(
client, email: str, filter_ids: list[str]
) -> tuple[int, list[str]]:
"""Delete Gmail filters by ID."""
deleted = 0
errors = []
for filter_id in filter_ids:
try:
await client.call_tool(
"delete_gmail_filter",
{"user_google_email": email, "filter_id": filter_id},
)
deleted += 1
except Exception as e:
errors.append(f"Failed to delete filter {filter_id}: {e}")
return deleted, errors
async def delete_gmail_labels(
client, email: str, label_ids: list[str]
) -> tuple[int, list[str]]:
"""Delete Gmail labels by ID."""
deleted = 0
errors = []
for label_id in label_ids:
try:
await client.call_tool(
"manage_gmail_label",
{"user_google_email": email, "action": "delete", "label_id": label_id},
)
deleted += 1
except Exception as e:
errors.append(f"Failed to delete label {label_id}: {e}")
return deleted, errors
async def delete_drive_files(
client, email: str, file_ids: list[str]
) -> tuple[int, list[str]]:
"""Delete Drive files by ID (moves to trash)."""
errors = []
try:
await client.call_tool(
"manage_drive_files",
{
"user_google_email": email,
"operation": "delete",
"file_ids": file_ids,
"permanent": False,
},
)
return len(file_ids), errors
except Exception as e:
errors.append(f"Failed to delete files: {e}")
return 0, errors
async def run_cleanup(
email: str,
execute: bool = False,
calendar: bool = True,
gmail_filters: bool = True,
gmail_labels: bool = True,
drive: bool = True,
) -> dict:
"""Run the cleanup process."""
results = {}
print(f"\n{'=' * 60}")
print("π§Ή TEST RESOURCE CLEANUP")
print(f"{'=' * 60}")
print(f" Email: {email}")
print(
f" Mode: {'EXECUTE (will delete)' if execute else 'DRY RUN (preview only)'}"
)
print(
f" Resources: calendar={calendar}, gmail_filters={gmail_filters}, gmail_labels={gmail_labels}, drive={drive}"
)
print()
try:
client = await create_cleanup_client(email)
async with client:
# Find test resources
if calendar:
print("π
Searching for test calendar events...")
cal_result = await find_test_calendar_events(client, email)
results["calendar"] = cal_result
print(f" Found: {cal_result.found} event(s)")
if cal_result.items:
for item in cal_result.items[:5]:
print(
f" - {item.get('summary', 'N/A')} ({item.get('id', 'N/A')[:20]}...)"
)
if len(cal_result.items) > 5:
print(f" ... and {len(cal_result.items) - 5} more")
if cal_result.errors:
print(f" Errors: {cal_result.errors}")
if gmail_filters:
print("\nπ§ Searching for test Gmail filters...")
filter_result = await find_test_gmail_filters(client, email)
results["gmail_filters"] = filter_result
print(f" Found: {filter_result.found} filter(s)")
if filter_result.items:
for item in filter_result.items[:5]:
criteria = item.get("criteria", {})
print(
f" - from:{criteria.get('from', 'N/A')} ({item.get('id', 'N/A')[:20]}...)"
)
if len(filter_result.items) > 5:
print(f" ... and {len(filter_result.items) - 5} more")
if filter_result.errors:
print(f" Errors: {filter_result.errors}")
if gmail_labels:
print("\nπ·οΈ Searching for test Gmail labels...")
label_result = await find_test_gmail_labels(client, email)
results["gmail_labels"] = label_result
print(f" Found: {label_result.found} label(s)")
if label_result.items:
for item in label_result.items[:5]:
print(
f" - {item.get('name', 'N/A')} ({item.get('id', 'N/A')[:20]}...)"
)
if len(label_result.items) > 5:
print(f" ... and {len(label_result.items) - 5} more")
if label_result.errors:
print(f" Errors: {label_result.errors}")
if drive:
print("\nπ Searching for test Drive files...")
drive_result = await find_test_drive_files(client, email)
results["drive"] = drive_result
print(f" Found: {drive_result.found} file(s)")
if drive_result.items:
for item in drive_result.items[:5]:
print(
f" - {item.get('name', 'N/A')} ({item.get('id', 'N/A')[:20]}...)"
)
if len(drive_result.items) > 5:
print(f" ... and {len(drive_result.items) - 5} more")
if drive_result.errors:
print(f" Errors: {drive_result.errors}")
# Execute deletion if requested
if execute:
print(f"\n{'=' * 60}")
print("ποΈ DELETING RESOURCES")
print(f"{'=' * 60}")
if calendar and results.get("calendar") and results["calendar"].items:
event_ids = [item["id"] for item in results["calendar"].items]
deleted, errors = await delete_calendar_events(
client, email, event_ids
)
results["calendar"].deleted = deleted
results["calendar"].errors.extend(errors)
print(f" Calendar events deleted: {deleted}/{len(event_ids)}")
if (
gmail_filters
and results.get("gmail_filters")
and results["gmail_filters"].items
):
filter_ids = [item["id"] for item in results["gmail_filters"].items]
deleted, errors = await delete_gmail_filters(
client, email, filter_ids
)
results["gmail_filters"].deleted = deleted
results["gmail_filters"].errors.extend(errors)
print(f" Gmail filters deleted: {deleted}/{len(filter_ids)}")
if (
gmail_labels
and results.get("gmail_labels")
and results["gmail_labels"].items
):
label_ids = [item["id"] for item in results["gmail_labels"].items]
deleted, errors = await delete_gmail_labels(
client, email, label_ids
)
results["gmail_labels"].deleted = deleted
results["gmail_labels"].errors.extend(errors)
print(f" Gmail labels deleted: {deleted}/{len(label_ids)}")
if drive and results.get("drive") and results["drive"].items:
file_ids = [item["id"] for item in results["drive"].items]
deleted, errors = await delete_drive_files(client, email, file_ids)
results["drive"].deleted = deleted
results["drive"].errors.extend(errors)
print(f" Drive files deleted: {deleted}/{len(file_ids)}")
else:
print(f"\n{'=' * 60}")
print("βΉοΈ DRY RUN COMPLETE - No resources were deleted")
print(" Run with --execute to actually delete resources")
print(f"{'=' * 60}")
except Exception as e:
print(f"\nβ Cleanup failed: {e}")
raise
# Summary
print(f"\n{'=' * 60}")
print("π SUMMARY")
print(f"{'=' * 60}")
total_found = sum(r.found for r in results.values() if hasattr(r, "found"))
total_deleted = sum(r.deleted for r in results.values() if hasattr(r, "deleted"))
total_errors = sum(len(r.errors) for r in results.values() if hasattr(r, "errors"))
print(f" Total found: {total_found}")
print(f" Total deleted: {total_deleted}")
print(f" Total errors: {total_errors}")
print(f"{'=' * 60}\n")
return results
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Clean up orphaned test resources from Google Workspace",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--execute",
action="store_true",
help="Actually delete resources (default is dry run)",
)
parser.add_argument(
"--email",
default=os.getenv("TEST_EMAIL_ADDRESS", "test_example@gmail.com"),
help="Google account email to use",
)
parser.add_argument(
"--calendar",
action="store_true",
default=None,
help="Clean up calendar events",
)
parser.add_argument(
"--gmail-filters",
action="store_true",
default=None,
help="Clean up Gmail filters",
)
parser.add_argument(
"--gmail-labels",
action="store_true",
default=None,
help="Clean up Gmail labels",
)
parser.add_argument(
"--drive",
action="store_true",
default=None,
help="Clean up Drive files",
)
args = parser.parse_args()
# If no specific resources requested, clean all
if (
args.calendar is None
and args.gmail_filters is None
and args.gmail_labels is None
and args.drive is None
):
args.calendar = True
args.gmail_filters = True
args.gmail_labels = True
args.drive = True
else:
args.calendar = args.calendar or False
args.gmail_filters = args.gmail_filters or False
args.gmail_labels = args.gmail_labels or False
args.drive = args.drive or False
asyncio.run(
run_cleanup(
email=args.email,
execute=args.execute,
calendar=args.calendar,
gmail_filters=args.gmail_filters,
gmail_labels=args.gmail_labels,
drive=args.drive,
)
)
if __name__ == "__main__":
main()