#!/usr/bin/env python3
"""
Blueworks Live All Activities Chart Generator
This script fetches multiple activity types from Blueworks Live API and creates
an Excel file with separate worksheets for each activity type plus a combined chart.
"""
import requests
import json
from datetime import datetime, timedelta
from collections import defaultdict
import openpyxl
from openpyxl.chart import LineChart, Reference
from openpyxl.chart.marker import Marker
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.drawing.line import LineProperties
import sys
# ============================================================================
# CONFIGURATION - Modify these values according to your needs
# ============================================================================
# API Configuration
API_BASE_URL = "" # Your Blueworks Live server URL (Required)
ACCESS_TOKEN = "" # Your Bearer access token (Required)
VERIFY_SSL = True
# Date Range Configuration
# Format: "YYYY-MM-DDTHH:MM:SS.sss±HH:MM" (ISO 8601)
# WARNING: The Blueworks Live API has a maximum 31-day range limit
START_DATE = "" # Start date for data fetch (Required)
END_DATE = "" # End date for data fetch (max 31 days from start) (Required)
# Output Configuration
OUTPUT_FILENAME = "blueworks_all_activities_chart.xlsx" # Name of the generated Excel file
# Activity Types to Fetch
# You can comment out any activity type you don't want to include
ACTIVITY_TYPES = {
"LOGINS": "User Logins",
"ITEMS_VIEWED": "Items Viewed",
"ITEMS_CHANGED": "Items Changed",
"SNAPSHOTS": "Snapshots"
}
# Chart Configuration
CHART_HEIGHT = 16 # Chart height in units
CHART_WIDTH = 28 # Chart width in units
MARKER_SIZE = 7 # Size of circular markers on data points
LINE_WIDTH = 25000 # Line width in EMUs (25000 = 2.5pt)
# Color Configuration for Users (in individual activity charts)
# Note: If you have more users than colors, the script will automatically cycle through them
# Add more colors here if you have many users to avoid color repetition
USER_COLORS = [
"4472C4", # Blue
"ED7D31", # Orange
"A5A5A5", # Gray
"FFC000", # Yellow
"5B9BD5", # Light Blue
"70AD47", # Green
"264478", # Dark Blue
"9E480E", # Brown
"636363", # Dark Gray
"997300", # Dark Yellow
"C00000", # Red
"7030A0", # Purple
"00B0F0", # Sky Blue
"92D050", # Light Green
"FF6600", # Bright Orange
"00B050", # Emerald
"0070C0", # Ocean Blue
"C65911", # Rust
"7F7F7F", # Medium Gray
"FFC90E", # Gold
]
# ============================================================================
# END OF CONFIGURATION
# ============================================================================
def fetch_activities(start_date, end_date, activity_type, access_token, base_url, verify_ssl=True):
"""
Fetch activities from Blueworks Live API with comprehensive error handling
Args:
start_date (str): Start date in ISO 8601 format
end_date (str): End date in ISO 8601 format
activity_type (str): Type of activity (LOGINS, ITEMS_VIEWED, etc.)
access_token (str): Bearer token for authentication
base_url (str): Base URL of the Blueworks Live server
verify_ssl (bool): Whether to verify SSL certificates
Returns:
dict: JSON response containing activity records, or empty dict with error info on failure
"""
# Build URL manually to avoid encoding issues
url = f"{base_url}/scr/api/activity?startDate={start_date}&endDate={end_date}&type={activity_type}"
headers = {
"Authorization": f"Bearer {access_token}"
}
# Disable SSL warnings if verification is disabled
if not verify_ssl:
try:
import urllib3 # type: ignore
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except ImportError:
# urllib3 not available, SSL warnings will still appear
pass
try:
print(f"Fetching {activity_type}...")
response = requests.get(url, headers=headers, verify=verify_ssl, timeout=30)
print(f"Response status: {response.status_code}")
# Handle different HTTP status codes
if response.status_code == 200:
try:
return response.json()
except json.JSONDecodeError as e:
print(f"⚠️ Warning: Invalid JSON response for {activity_type}")
print(f" Error: {e}")
return {"records": [], "error": "Invalid JSON response"}
elif response.status_code == 401:
print(f"❌ Authentication failed for {activity_type}")
print(f" Please check your ACCESS_TOKEN")
return {"records": [], "error": "Authentication failed"}
elif response.status_code == 403:
print(f"❌ Access forbidden for {activity_type}")
print(f" You may not have permission to access this resource")
return {"records": [], "error": "Access forbidden"}
elif response.status_code == 400:
error_msg = response.text[:200]
print(f"❌ Bad request for {activity_type}")
if "31 days" in error_msg or "date" in error_msg.lower():
print(f" Date range issue: {error_msg}")
print(f" The API requires date ranges of 31 days or less")
else:
print(f" Error: {error_msg}")
return {"records": [], "error": "Bad request - check date range"}
elif response.status_code == 404:
print(f"⚠️ Warning: Endpoint not found for {activity_type}")
print(f" URL: {url}")
return {"records": [], "error": "Endpoint not found"}
elif response.status_code >= 500:
print(f"❌ Server error ({response.status_code}) for {activity_type}")
print(f" The Blueworks Live server may be experiencing issues")
return {"records": [], "error": f"Server error {response.status_code}"}
else:
print(f"⚠️ Unexpected status code {response.status_code} for {activity_type}")
print(f" Response: {response.text[:200]}")
return {"records": [], "error": f"HTTP {response.status_code}"}
except requests.exceptions.Timeout:
print(f"❌ Request timeout for {activity_type}")
print(f" The server took too long to respond (>30 seconds)")
return {"records": [], "error": "Request timeout"}
except requests.exceptions.SSLError as e:
print(f"❌ SSL certificate error for {activity_type}")
print(f" Try setting VERIFY_SSL = False for self-signed certificates")
print(f" Error: {str(e)[:100]}")
return {"records": [], "error": "SSL certificate error"}
except requests.exceptions.ConnectionError as e:
print(f"❌ Connection error for {activity_type}")
print(f" Could not connect to {base_url}")
print(f" Error: {str(e)[:100]}")
return {"records": [], "error": "Connection failed"}
except requests.exceptions.RequestException as e:
print(f"❌ Unexpected error fetching {activity_type}")
print(f" Error: {str(e)[:100]}")
if hasattr(e, 'response') and e.response is not None:
print(f" Response: {e.response.text[:200]}")
return {"records": [], "error": f"Request failed: {type(e).__name__}"}
except Exception as e:
print(f"❌ Unexpected error for {activity_type}")
print(f" Error: {str(e)[:100]}")
return {"records": [], "error": f"Unexpected error: {type(e).__name__}"}
def parse_activity_data(api_response):
"""
Parse API response and aggregate activity counts by user and date
Args:
api_response (dict): JSON response from API
Returns:
dict: Nested dictionary with structure {date: {user: count}}
"""
activity_counts = defaultdict(lambda: defaultdict(int))
for record in api_response.get("records", []):
user = record.get("user")
timestamp = record.get("timeStamp")
if user and timestamp:
# Extract date from timestamp (YYYY-MM-DD)
date = timestamp.split("T")[0]
activity_counts[date][user] += 1
return activity_counts
def create_data_worksheet(wb, sheet_name, activity_counts, all_users, all_dates):
"""Create a data worksheet with formatted data"""
ws = wb.create_sheet(sheet_name)
header_color = "2C3E50"
alt_row_color = "ECF0F1"
# Headers
ws["A1"] = "Date"
ws["A1"].font = Font(bold=True, color="FFFFFF", size=12)
ws["A1"].fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
for idx, user in enumerate(all_users, start=2):
cell = ws.cell(row=1, column=idx)
cell.value = user.title()
cell.font = Font(bold=True, color="FFFFFF", size=12)
cell.fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
# Data rows with formatted dates
for row_idx, date in enumerate(all_dates, start=2):
# Format date as "DD MMM"
try:
date_obj = datetime.strptime(date, "%Y-%m-%d")
formatted_date = date_obj.strftime("%d %b")
except:
formatted_date = date
date_cell = ws.cell(row=row_idx, column=1, value=formatted_date)
date_cell.alignment = Alignment(horizontal="center", vertical="center")
date_cell.font = Font(size=11)
if row_idx % 2 == 0:
date_cell.fill = PatternFill(start_color=alt_row_color, end_color=alt_row_color, fill_type="solid")
for col_idx, user in enumerate(all_users, start=2):
count = activity_counts[date].get(user, 0)
count_cell = ws.cell(row=row_idx, column=col_idx, value=count)
count_cell.alignment = Alignment(horizontal="center", vertical="center")
count_cell.font = Font(size=11)
if row_idx % 2 == 0:
count_cell.fill = PatternFill(start_color=alt_row_color, end_color=alt_row_color, fill_type="solid")
# Auto-adjust column widths
for col_idx in range(1, len(all_users) + 2):
column_letter = get_column_letter(col_idx)
ws.column_dimensions[column_letter].width = 15
# Add borders
thin_border = Border(
left=Side(style='thin', color='CCCCCC'),
right=Side(style='thin', color='CCCCCC'),
top=Side(style='thin', color='CCCCCC'),
bottom=Side(style='thin', color='CCCCCC')
)
for row in ws.iter_rows(min_row=1, max_row=len(all_dates) + 1,
min_col=1, max_col=len(all_users) + 1):
for cell in row:
cell.border = thin_border
return ws
def create_chart(ws_data, all_users, all_dates, title, user_colors):
"""Create a spline chart for the data"""
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.drawing.line import LineProperties as DrawingLineProperties
# Individual activity charts with specific dimensions
# Height: 6.3 inches = 16 cm
# Width: 17.8 inches = 45.2 cm (rounded to 45)
chart = LineChart()
chart.style = 13
chart.title = title
chart.height = 16 # 6.3 inches
chart.width = 45 # 17.8 inches
chart.smooth = True
# Define data range
data = Reference(ws_data, min_col=2, min_row=1, max_row=len(all_dates) + 1, max_col=len(all_users) + 1)
cats = Reference(ws_data, min_col=1, min_row=2, max_row=len(all_dates) + 1)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
# Configure Y-axis - set minimum to 0 to avoid negative values
chart.y_axis.title = "Count"
chart.y_axis.crossAx = 500
chart.y_axis.axId = 100
chart.y_axis.delete = False
chart.y_axis.scaling.min = 0 # Prevent negative values on Y-axis
# Add light horizontal gridlines
if chart.y_axis.majorGridlines is None:
from openpyxl.chart.axis import ChartLines
chart.y_axis.majorGridlines = ChartLines()
gridline_props = GraphicalProperties()
gridline_line = DrawingLineProperties()
gridline_line.solidFill = "E0E0E0"
gridline_line.width = 9525
gridline_props.line = gridline_line
chart.y_axis.majorGridlines.spPr = gridline_props
# Configure X-axis
chart.x_axis.title = ""
chart.x_axis.crossAx = 100
chart.x_axis.axId = 500
chart.x_axis.delete = False
# Configure legend outside the plot area on the right
from openpyxl.chart.legend import Legend
legend = Legend()
legend.position = 'r' # Right position
legend.overlay = False # Place legend outside the plot area
chart.legend = legend
# Configure each series with colors
for idx, series in enumerate(chart.series):
series.smooth = True
marker = Marker('circle')
marker.size = MARKER_SIZE
series.marker = marker
color = user_colors[idx % len(user_colors)]
line = LineProperties()
line.width = LINE_WIDTH
line.solidFill = color
series.graphicalProperties.line = line
if series.marker and series.marker.graphicalProperties:
series.marker.graphicalProperties.solidFill = color
return chart
def create_combined_chart(wb, all_activity_data, all_dates):
"""Create a combined chart showing all activity types"""
ws_combined = wb.create_sheet("Combined Overview", 0)
# Title
ws_combined["A1"] = "Blueworks Live - All Activities Overview"
ws_combined["A1"].font = Font(bold=True, size=16, color="2C3E50")
ws_combined["A1"].alignment = Alignment(horizontal="left", vertical="center")
ws_combined.merge_cells("A1:F1")
ws_combined.row_dimensions[1].height = 30
# Create data table for combined chart
header_color = "2C3E50"
ws_combined["A3"] = "Date"
ws_combined["A3"].font = Font(bold=True, color="FFFFFF", size=12)
ws_combined["A3"].fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
ws_combined["A3"].alignment = Alignment(horizontal="center", vertical="center")
# Add activity type headers
for idx, (activity_type, activity_name) in enumerate(ACTIVITY_TYPES.items(), start=2):
cell = ws_combined.cell(row=3, column=idx)
cell.value = activity_name
cell.font = Font(bold=True, color="FFFFFF", size=12)
cell.fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
# Add data rows
for row_idx, date in enumerate(all_dates, start=4):
# Format date
try:
date_obj = datetime.strptime(date, "%Y-%m-%d")
formatted_date = date_obj.strftime("%d %b")
except:
formatted_date = date
ws_combined.cell(row=row_idx, column=1, value=formatted_date)
# Add counts for each activity type
for col_idx, activity_type in enumerate(ACTIVITY_TYPES.keys(), start=2):
total_count = sum(all_activity_data[activity_type][date].values()) if date in all_activity_data[activity_type] else 0
ws_combined.cell(row=row_idx, column=col_idx, value=total_count)
# Create combined chart with specific dimensions
# Height: 6.54 inches = 16.6 cm (rounded to 17)
# Width: 13.8 inches = 35.05 cm (rounded to 35)
chart = LineChart()
chart.style = 13
chart.title = "All Activities Over Time"
chart.height = 17 # 6.54 inches
chart.width = 40 # 13.8 inches
chart.smooth = True
data = Reference(ws_combined, min_col=2, min_row=3, max_row=len(all_dates) + 3, max_col=len(ACTIVITY_TYPES) + 1)
cats = Reference(ws_combined, min_col=1, min_row=4, max_row=len(all_dates) + 3)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
# Configure axes - set minimum to 0 to avoid negative values
chart.y_axis.title = "Count"
chart.y_axis.delete = False
chart.y_axis.scaling.min = 0 # Prevent negative values on Y-axis
chart.x_axis.title = ""
chart.x_axis.delete = False
# Add gridlines
from openpyxl.chart.axis import ChartLines
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.drawing.line import LineProperties as DrawingLineProperties
if chart.y_axis.majorGridlines is None:
chart.y_axis.majorGridlines = ChartLines()
gridline_props = GraphicalProperties()
gridline_line = DrawingLineProperties()
gridline_line.solidFill = "E0E0E0"
gridline_line.width = 9525
gridline_props.line = gridline_line
chart.y_axis.majorGridlines.spPr = gridline_props
# Configure legend outside the plot area at bottom
from openpyxl.chart.legend import Legend
legend = Legend()
legend.position = 'b' # Bottom position
legend.overlay = False # Place legend outside the plot area
chart.legend = legend
# Different colors for each activity type
activity_colors = ["4472C4", "ED7D31", "70AD47", "FFC000"]
for idx, series in enumerate(chart.series):
series.smooth = True
marker = Marker('circle')
marker.size = 8
series.marker = marker
color = activity_colors[idx % len(activity_colors)]
line = LineProperties()
line.width = 28000
line.solidFill = color
series.graphicalProperties.line = line
if series.marker and series.marker.graphicalProperties:
series.marker.graphicalProperties.solidFill = color
# Position chart to the right of the data table
# Data table uses columns A through E (5 columns for 4 activity types + date)
# Start chart at column G (column 7) to leave one column buffer
chart_column = "G"
chart_row = 3 # Start at same row as data table header
ws_combined.add_chart(chart, f"{chart_column}{chart_row}") # type: ignore
# Adjust column widths
for col_idx in range(1, len(ACTIVITY_TYPES) + 2):
column_letter = get_column_letter(col_idx)
ws_combined.column_dimensions[column_letter].width = 18
def main():
"""Main execution function with comprehensive error handling"""
print("Blueworks Live All Activities Chart Generator")
print("=" * 60)
# Date range - use configuration values
start_date_str = START_DATE
end_date_str = END_DATE
# Validate date range
try:
from dateutil import parser
start_dt = parser.isoparse(start_date_str)
end_dt = parser.isoparse(end_date_str)
date_diff = (end_dt - start_dt).days
# Extract display dates
start_display = start_dt.strftime("%Y-%m-%d")
end_display = end_dt.strftime("%Y-%m-%d")
print(f"Fetching activity data from {start_display} to {end_display}")
print(f"Date range: {date_diff} days")
# Warn if exceeding API limit
if date_diff > 31:
print("\n⚠️ WARNING: Date range exceeds 31 days!")
print(f" Requested: {date_diff} days")
print(f" API Limit: 31 days maximum")
print(f" The API may reject this request or return incomplete data")
print(f" Consider splitting into multiple 31-day periods\n")
elif date_diff < 0:
print("\n❌ ERROR: End date is before start date!")
print("=" * 60)
return
except ImportError:
# dateutil not available, use simple parsing
print(f"Fetching activity data from {start_date_str} to {end_date_str}")
print("⚠️ Note: Install python-dateutil for date range validation")
except Exception as e:
print(f"⚠️ Warning: Could not validate date range: {e}")
print(f"Fetching activity data from {start_date_str} to {end_date_str}")
print("-" * 60)
# Fetch all activity types
all_activity_data = {}
all_users_set = set()
all_dates_set = set()
failed_activities = []
successful_activities = []
for activity_type, activity_name in ACTIVITY_TYPES.items():
api_response = fetch_activities(
start_date_str,
end_date_str,
activity_type,
ACCESS_TOKEN,
API_BASE_URL,
VERIFY_SSL
)
# Check if the API call had errors
if "error" in api_response:
failed_activities.append((activity_name, api_response.get("error")))
print(f" {activity_name}: ⚠️ Failed - {api_response.get('error')}")
else:
record_count = len(api_response.get('records', []))
if record_count > 0:
successful_activities.append(activity_name)
print(f" {activity_name}: ✓ {record_count} records")
activity_counts = parse_activity_data(api_response)
all_activity_data[activity_type] = activity_counts
# Collect all users and dates
for date, users in activity_counts.items():
all_dates_set.add(date)
all_users_set.update(users.keys())
all_dates = sorted(all_dates_set)
all_users = sorted(all_users_set)
print("-" * 60)
# Summary of API calls
if failed_activities:
print(f"\n⚠️ Warning: {len(failed_activities)} activity type(s) failed to fetch:")
for activity_name, error in failed_activities:
print(f" - {activity_name}: {error}")
if successful_activities:
print(f"\n✓ Successfully fetched {len(successful_activities)} activity type(s):")
for activity_name in successful_activities:
print(f" - {activity_name}")
# Check if we have any data at all
if not all_dates:
print("\n" + "=" * 60)
print("❌ ERROR: No activity data available!")
print("=" * 60)
print("\nPossible reasons:")
print(" 1. All API calls failed (check errors above)")
print(" 2. No activities occurred in the specified date range")
print(" 3. Authentication or permission issues")
print("\nPlease check:")
print(" - API_BASE_URL is correct")
print(" - ACCESS_TOKEN is valid and not expired")
print(" - Date range contains actual activity data")
print(" - Network connectivity to the server")
print("=" * 60)
return
if not all_users:
print("\n⚠️ Warning: No user data found in the responses")
print(" The Excel file will be created but may be empty")
print(f"\nTotal unique users: {len(all_users)}")
print(f"Total unique dates: {len(all_dates)}")
print("-" * 60)
# Create workbook with error handling
try:
wb = openpyxl.Workbook()
if wb.active:
wb.remove(wb.active)
except Exception as e:
print(f"\n❌ ERROR: Failed to create Excel workbook")
print(f" Error: {str(e)}")
print("=" * 60)
return
# User colors
user_colors = [
"4472C4", "ED7D31", "A5A5A5", "FFC000",
"5B9BD5", "70AD47", "264478", "9E480E",
"636363", "997300"
]
# Create combined overview first
create_combined_chart(wb, all_activity_data, all_dates)
# Create individual worksheets for each activity type
for activity_type, activity_name in ACTIVITY_TYPES.items():
activity_counts = all_activity_data[activity_type]
# Create data worksheet
ws_data = create_data_worksheet(
wb,
f"{activity_name} Data",
activity_counts,
all_users,
all_dates
)
# Create dashboard worksheet
ws_dashboard = wb.create_sheet(f"{activity_name} Dashboard")
# Title
ws_dashboard["A1"] = f"Blueworks Live - {activity_name}"
ws_dashboard["A1"].font = Font(bold=True, size=16, color="2C3E50")
ws_dashboard["A1"].alignment = Alignment(horizontal="left", vertical="center")
ws_dashboard.merge_cells("A1:F1")
ws_dashboard.row_dimensions[1].height = 30
# Summary statistics - improved tabular format
ws_dashboard["A3"] = "Summary Statistics"
ws_dashboard["A3"].font = Font(bold=True, size=14, color="FFFFFF")
ws_dashboard["A3"].fill = PatternFill(start_color="2C3E50", end_color="2C3E50", fill_type="solid")
ws_dashboard["A3"].alignment = Alignment(horizontal="center", vertical="center")
ws_dashboard.merge_cells("A3:B3")
ws_dashboard.row_dimensions[3].height = 25
total_activities = sum(sum(users.values()) for users in activity_counts.values())
# Format date range nicely
date_range_str = "N/A"
if all_dates:
try:
start_date_obj = datetime.strptime(all_dates[0], "%Y-%m-%d")
end_date_obj = datetime.strptime(all_dates[-1], "%Y-%m-%d")
date_range_str = f"{start_date_obj.strftime('%d %b %Y')} to {end_date_obj.strftime('%d %b %Y')}"
except:
date_range_str = f"{all_dates[0]} to {all_dates[-1]}"
summary_data = [
["Total Events", total_activities],
["Unique Users", len([u for u in all_users if any(activity_counts[d].get(u, 0) > 0 for d in all_dates)])],
["Date Range", date_range_str],
["Total Days", len(all_dates)]
]
# Create bordered table with alternating row colors
header_color = "34495E"
alt_row_color = "ECF0F1"
border_style = Border(
left=Side(style='thin', color='95A5A6'),
right=Side(style='thin', color='95A5A6'),
top=Side(style='thin', color='95A5A6'),
bottom=Side(style='thin', color='95A5A6')
)
for idx, (label, value) in enumerate(summary_data, start=4):
# Label cell
label_cell = ws_dashboard.cell(row=idx, column=1, value=label)
label_cell.font = Font(bold=True, size=11, color="FFFFFF")
label_cell.fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
label_cell.alignment = Alignment(horizontal="left", vertical="center")
label_cell.border = border_style
# Value cell with alternating colors
value_cell = ws_dashboard.cell(row=idx, column=2, value=value)
value_cell.font = Font(size=11, bold=True)
value_cell.fill = PatternFill(start_color=alt_row_color, end_color=alt_row_color, fill_type="solid")
value_cell.alignment = Alignment(horizontal="center", vertical="center")
value_cell.border = border_style
# Set row height for better spacing
ws_dashboard.row_dimensions[idx].height = 22
# Create chart
chart = create_chart(ws_data, all_users, all_dates, f"{activity_name} Over Time", user_colors)
ws_dashboard.add_chart(chart, "A10") # type: ignore
# Adjust column widths
ws_dashboard.column_dimensions['A'].width = 25
ws_dashboard.column_dimensions['B'].width = 20
# Save workbook with error handling
output_file = OUTPUT_FILENAME
try:
wb.save(output_file)
print(f"\n✓ Excel file created successfully: {output_file}")
print("=" * 60)
except PermissionError:
print(f"\n❌ ERROR: Permission denied when saving {output_file}")
print(f" The file may be open in another application")
print(f" Please close the file and try again")
print("=" * 60)
return
except OSError as e:
print(f"\n❌ ERROR: Failed to save Excel file")
print(f" Error: {str(e)}")
print(f" Check disk space and file permissions")
print("=" * 60)
return
except Exception as e:
print(f"\n❌ ERROR: Unexpected error while saving Excel file")
print(f" Error: {str(e)}")
print("=" * 60)
return
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n⚠️ Operation cancelled by user")
print("=" * 60)
sys.exit(0)
except Exception as e:
print(f"\n❌ FATAL ERROR: Unexpected error occurred")
print(f" Error: {str(e)}")
print(f" Type: {type(e).__name__}")
print("=" * 60)
sys.exit(1)