From 7df12119fbeda257e23196411ae8d8645e98bbc4 Mon Sep 17 00:00:00 2001 From: arthur Date: Wed, 20 Aug 2025 14:03:31 +0700 Subject: [PATCH] first push --- .DS_Store | Bin 0 -> 8196 bytes .gitignore | 10 + .python-version | 1 + .vscode/settings.json | 3 + CLAUDE.md | 59 ++++ README.md | 0 analyze_excel.py | 92 ++++++ data/sample-data.xlsx | Bin 0 -> 133010 bytes data/~$sample-data.xlsx | Bin 0 -> 165 bytes data_comparator.py | 488 ++++++++++++++++++++++++++++++ gui_app.py | 319 ++++++++++++++++++++ main.py | 8 + pyproject.toml | 12 + templates/index.html | 515 ++++++++++++++++++++++++++++++++ test_grouping.py | 28 ++ test_sheet_filtering.py | 38 +++ test_simplified_gui.py | 35 +++ test_upload.py | 58 ++++ uv.lock | 342 +++++++++++++++++++++ web_gui.py | 637 ++++++++++++++++++++++++++++++++++++++++ 20 files changed, 2645 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .vscode/settings.json create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 analyze_excel.py create mode 100644 data/sample-data.xlsx create mode 100644 data/~$sample-data.xlsx create mode 100644 data_comparator.py create mode 100644 gui_app.py create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 templates/index.html create mode 100644 test_grouping.py create mode 100644 test_sheet_filtering.py create mode 100644 test_simplified_gui.py create mode 100644 test_upload.py create mode 100644 uv.lock create mode 100644 web_gui.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..34ac6eaaeae72046df8447bcba9aa61cc9a317be GIT binary patch literal 8196 zcmeHMU2GIp6h3EK=*&RnQYdsb{0!X)YFF4+X!sFW+M-m7t^IHLS!X*VjGfMuo!MQW zY0_x?*Z3sgf{8!TG>Dq`q7O!SGErVMdhXoWQYbu0l!Up-+;h*l z=iEEz-0#fYdlvw(rC>G!)B%7@52tJaRo5uIpFeYo6iF(HBH2T(+JiYH?HFd#1Xh9Bf!58Iz60bB042#`01c3JOU8q zM}V+UoACxwJ))V2P6@>a} zssm;Ojq5l9aRlZ@fTvF-m|#Ev#_aQZZrri`Ae}ysQbpyw`Bie2yg)wKbHpq41V&Ks zTa5gE&h>hplPkB4T)%CVvTSLq=LUxDTKUkxHZ4*)Fkri;S8DMJuIY!iJ)%QaWF^a% zPfRp7H>LFSWJ;gd(v(i=jg3v4CMOlSHo0M2cXrenbG>JT2N-b?kMU4t$#or8nj0|x5qwVW!WO1{Ank)D{mFakAV@-DU7*(mMtDJoRNZK z1lBIc;mZDCU>ys1YuNYlL2Ia(Wvt*^-4^NDMXQap$+U)#)Hxp5p6mBpd7o%9lNPV7 zoLX4BvVLttdehdOZF@SprmJsT#1^Yd)L{yY8`zK7R({CvM=aO0-NSt)+YF8j6m8$m zIaaT0j9JQJJfuu7TV7YKCA1agE}?kYP-aPau#mHl7VO}7IL9hYR}YxUM2##2XtU7k%;|CBzx6MLWc2?lAnIbKG+ITb&bqyQy#3KVsyq z&JuwtdZ~7jhO0($ZnWF91CMux0nxQiSsvm8?lMiwoUUG8x9p*AjVze$7HtACErq}` z`&yReWacihDk8E=uR<2zpfsq5jc_k?!4RcP37&_S;3T{Wr{FAn0-wTHa2~#a@8Em* z2`<8K@F)BQeGL#Nabu97JRvuTBtRX{FE)x<(+i&TG^7g?|!xYf6bO}7l!#!vt()Qs^lGa zH#Kj|%-~1>uZ*?X2o7O;1=vH_hO2xP-Z*fygt}s7Ig>8ukjSY%!n<`$=AdO&k@lK; znefi$MeN#j#O`JD1)FF{$&_163RyLgZd53PSVhD(Y$6r~qi)%%P%g2`h~2SM;pxpF zY!JgrDIOfm0@M*5RrUph>(P*W(6EV-s$~?YIMX z;x2+}2kyfMu?xEis0RqDSv2rb97TuVTEb&^94By+!1@$EjnCl=coJVG*uECQ_8ojz zKyC(z50oJ&D1JB_h{c|~>v-;Al9hpbrFzWWm@hEWw;t1T7 z5x~l}o;Lnl;Ci21e%99Md6*tvxZaeYp$S#tahzy)949*Shat6fQsp+8h)xMg5-R`s SMSx|-`+vOuhuFK7tA7A~pb{Pc literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5480842 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Disabled" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..97532ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Python data comparison tool built with Python 3.13+. The project is currently in early development with a minimal structure containing: + +- A basic Python application entry point (`main.py`) +- Sample data in Excel format (`data/sample-data.xlsx`) +- Standard Python packaging configuration (`pyproject.toml`) + +## Development Commands + +### Running the Application +```bash +uv run main.py +``` +This launches a web-based GUI at http://localhost:8080 + +### Running Analysis Only (Command Line) +```bash +uv run data_comparator.py +``` + +### Project Setup +The project uses Python 3.13+ with uv for dependency management. Dependencies include: +- pandas (Excel file processing) +- openpyxl (Excel file reading) +- flask (Web GUI) + +## Project Structure + +- `main.py` - Main application entry point that launches the web GUI +- `data_comparator.py` - Core comparison logic for KST vs Coordi data analysis +- `web_gui.py` - Flask-based web GUI application +- `analyze_excel.py` - Basic Excel file structure analysis utility +- `data/` - Directory containing sample data files + - `sample-data.xlsx` - Sample Excel data file for comparison operations +- `templates/` - HTML templates for web GUI (auto-generated) +- `pyproject.toml` - Python project configuration and metadata + +## Key Features + +- **KST vs Coordi Comparison**: Compares data between KST columns (`Title KR`, `Epi.`) and Coordi columns (`KR title`, `Chap`) +- **Mismatch Categorization**: Identifies KST-only, Coordi-only, and duplicate items +- **Data Reconciliation**: Ensures matching counts after excluding mismatches +- **Web-based GUI**: Interactive interface with tabs for different data views +- **File Upload**: Upload Excel files directly through the web interface +- **Sheet Filtering**: Filter results by specific Excel sheets +- **Real-time Analysis**: Live comparison with detailed mismatch reasons + +## Comparison Logic + +The tool compares Excel data by: +1. Finding columns by header names (not positions) +2. Extracting title+episode combinations from both datasets +3. Categorizing mismatches and calculating reconciliation +4. Displaying results with reasons for each discrepancy \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/analyze_excel.py b/analyze_excel.py new file mode 100644 index 0000000..4f6887b --- /dev/null +++ b/analyze_excel.py @@ -0,0 +1,92 @@ +import pandas as pd +import os +from pathlib import Path + +def analyze_excel_structure(file_path): + """ + Analyze the structure of an Excel file and return detailed information. + """ + if not os.path.exists(file_path): + return f"Error: File {file_path} does not exist" + + try: + # Read Excel file to get sheet names + excel_file = pd.ExcelFile(file_path) + + analysis = { + 'file_path': file_path, + 'file_size': os.path.getsize(file_path), + 'sheet_names': excel_file.sheet_names, + 'total_sheets': len(excel_file.sheet_names), + 'sheets_analysis': {} + } + + # Analyze each sheet + for sheet_name in excel_file.sheet_names: + df = pd.read_excel(file_path, sheet_name=sheet_name) + + sheet_info = { + 'dimensions': df.shape, + 'columns': list(df.columns), + 'column_count': len(df.columns), + 'row_count': len(df), + 'data_types': df.dtypes.to_dict(), + 'missing_values': df.isnull().sum().to_dict(), + 'sample_data': df.head(3).to_dict('records') if not df.empty else [] + } + + analysis['sheets_analysis'][sheet_name] = sheet_info + + return analysis + + except Exception as e: + return f"Error analyzing file: {str(e)}" + +def print_analysis_report(analysis): + """ + Print a formatted report of the Excel file analysis. + """ + if isinstance(analysis, str): + print(analysis) + return + + print("=" * 60) + print("EXCEL FILE STRUCTURE ANALYSIS") + print("=" * 60) + + print(f"File: {analysis['file_path']}") + print(f"Size: {analysis['file_size']:,} bytes") + print(f"Total Sheets: {analysis['total_sheets']}") + print(f"Sheet Names: {', '.join(analysis['sheet_names'])}") + print() + + for sheet_name, sheet_info in analysis['sheets_analysis'].items(): + print(f"--- SHEET: {sheet_name} ---") + print(f"Dimensions: {sheet_info['dimensions'][0]} rows × {sheet_info['dimensions'][1]} columns") + print(f"Columns: {', '.join(sheet_info['columns'])}") + print() + + print("Data Types:") + for col, dtype in sheet_info['data_types'].items(): + print(f" {col}: {dtype}") + print() + + print("Missing Values:") + for col, missing in sheet_info['missing_values'].items(): + if missing > 0: + print(f" {col}: {missing} missing values") + print() + + if sheet_info['sample_data']: + print("Sample Data (first 3 rows):") + for i, row in enumerate(sheet_info['sample_data'], 1): + print(f" Row {i}: {row}") + print() + +if __name__ == "__main__": + # Analyze the sample Excel file + file_path = "data/sample-data.xlsx" + + print("Starting Excel file analysis...") + analysis = analyze_excel_structure(file_path) + print_analysis_report(analysis) \ No newline at end of file diff --git a/data/sample-data.xlsx b/data/sample-data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..720976a6f8d3fd78fc332b493cce13fb54f89a51 GIT binary patch literal 133010 zcmeEv30REl`#;ChMuQ@>4BAA5v}u`>ttk@OA{tprq*Bs8b4WTN)1Hb3O_HQiO6#aW zl1hsfHBF;^o%We&w*NbEG~=k5=70Up<$GPub@?9W%l*91b1$F!zCZ8%yzSjJYqt0d zK|#S8a}SA`&tUs!De!w&Tb0v}mM%7S)=vNYX5)I-3%2(@9=9vlFi)UgtVi@Y) zMTz#Qp{q5o)|~Jz+<7eEjU6>@nOb65PTZN4`21LH7~oB>Y!WcIzpO^Z%Ob-dzHM0w-;} z=ggcjLtxg78GEOz%1ysj$*(G1>>bZLojr5rl9S5#zs9}tF0S6OrNi=3_gXVj(?^Qg zm*`9HqF_-joMDN1!+rtYCz=*y2=OB(3s3`6DMIu7!uq%-N4M$6_AZmt?HRNoCN+*pFGewXO(3*36mtwrCy+2X%uh+t2nrz5hs2QU zg_!+uWp@XfF|=YLoQkCs;u%yhoZO6JUVevylj*E=Dw&-y1j!z9+=PlnTxSZ<(%$jAf1QT1vY-Zw_BUmDWR)(OxDZ|p|_7WK+41=Q5UyK+VQNc5_F$^p@8_XDr zD}gX^Cd{#BgaTp=2Wf|ZDU~n~r8t?12P0?@1f>SX(9*P`!U_@hUX_)7$zU=nQ3%?$ zF)Ti>1E<31Lm|lFcp8<+^c))?RT3GLGUiw+j!7TFW@kVc?No0RgVEZlQpg;64cMO+ z1w0=Gr@t!dyW2{9=O8CABk~;!q`Y|Zg)vF}gQYTl?LORtxW)~mjdQ2A)V+;9egUIFP*q08*?&?FL``?`XP zWGZ~4!WouMov_Sd>a0}V7LV1Vd;1=I-j?fY1?rW_(jE3#I=Z3H@-sHa_YA08=3%eT z{Ppx^YAm7wHl}-b)S(f31~ecOq>DyssL-3+Dliv2Ndi_L??`)l9yDxw;p+}6k@>89 z$)kg`p~tcTo962VDwe6%-Ci#ud2B3>w2PUB8TQcZCFJ-P%Y4z5^MIo4EXLwWVlmXk zHE_hqbYD-<8=3dI2Rt5=KK4AoZOib5gRnAhb$5H*Atm=%;sOK@h zl`^FsFMC!tZhP)KOlf<8XtbbeKch2V+kz-EH*`Zh9*^!NKB(K4?P~|3%UsvJ<#A_p z1JSY$o8^1fI!2}Y@uKPXb88qQ z^v{^SBu-7&MAa~yJ$k_}N2y}GXgYNMGkQJs1)?5C<@7CN738dPye~| z^j53W`iJC%pSiufX>8aK_mJsxH$c@<|D>FLh5d8fn{K#-3!YhdWt*y;fiQae8uZTP z=p9?o26InuS2(?M$>|*rn<9O)$&atNwEWp}?QWpRB3YrJ#v^m;-l&Vn(B1TxZlCIj z$OEnhYq#%tw=8qT#-J6Q8kAzNEt9?>y}ZA#&N5(rpp;U;qWFO2HUUdI17wy3EQ|

|Pf5U(a;lBx%Q)p$XpU`} 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 literal 0 HcmV?d00001 diff --git a/data/~$sample-data.xlsx b/data/~$sample-data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7719d1a3cc6cf9cd6292cbf990e012792c57ff86 GIT binary patch literal 165 ucmd;fEGo$;Em8<6O3YIr9&j@_G88eCFk~>40%-+?5FnYzkOySZ0s;W-yb~<| literal 0 HcmV?d00001 diff --git a/data_comparator.py b/data_comparator.py new file mode 100644 index 0000000..06cfd23 --- /dev/null +++ b/data_comparator.py @@ -0,0 +1,488 @@ +import pandas as pd +import numpy as np +from typing import Dict, List, Tuple, Any, Set +from dataclasses import dataclass + +@dataclass +class ComparisonItem: + """Represents a single item for comparison""" + title: str + episode: str + source_sheet: str + row_index: int + + def __hash__(self): + return hash((self.title, self.episode)) + + def __eq__(self, other): + if not isinstance(other, ComparisonItem): + return False + return self.title == other.title and self.episode == other.episode + +class KSTCoordiComparator: + """ + Compare KST and Coordi data to identify mismatches and ensure count reconciliation + """ + + def __init__(self, excel_file_path: str): + self.excel_file_path = excel_file_path + self.data = {} + self.kst_items = set() + self.coordi_items = set() + self.comparison_results = {} + + def load_data(self) -> bool: + """Load data from Excel file""" + try: + excel_file = pd.ExcelFile(self.excel_file_path) + for sheet_name in excel_file.sheet_names: + self.data[sheet_name] = pd.read_excel(self.excel_file_path, sheet_name=sheet_name) + return True + except Exception as e: + print(f"Error loading data: {e}") + return False + + def extract_kst_coordi_items(self) -> Dict[str, Any]: + """Extract KST and Coordi items from all sheets using column header names""" + kst_items = set() + coordi_items = set() + kst_details = [] + coordi_details = [] + + for sheet_name, df in self.data.items(): + columns = df.columns.tolist() + + # Find columns by header names + # KST columns: 'Title KR' and 'Epi.' + # Coordi columns: 'KR title' and 'Chap' + + kst_title_col = None + kst_episode_col = None + coordi_title_col = None + coordi_episode_col = None + + # Find KST columns + for col in columns: + if col == 'Title KR': + kst_title_col = col + elif col == 'Epi.': + kst_episode_col = col + + # Find Coordi columns + for col in columns: + if col == 'KR title': + coordi_title_col = col + elif col == 'Chap': + coordi_episode_col = col + + print(f"Sheet: {sheet_name}") + print(f" KST columns - Title: {kst_title_col}, Episode: {kst_episode_col}") + print(f" Coordi columns - Title: {coordi_title_col}, Episode: {coordi_episode_col}") + + # Extract items from each row + for idx, row in df.iterrows(): + # Extract KST data + if kst_title_col and kst_episode_col: + kst_title = str(row.get(kst_title_col, '')).strip() + kst_episode = str(row.get(kst_episode_col, '')).strip() + + # Check if this row has valid KST data + has_kst_data = ( + kst_title and kst_title != 'nan' and + kst_episode and kst_episode != 'nan' and + pd.notna(row[kst_title_col]) and pd.notna(row[kst_episode_col]) + ) + + if has_kst_data: + item = ComparisonItem(kst_title, kst_episode, sheet_name, idx) + kst_items.add(item) + kst_details.append({ + 'title': kst_title, + 'episode': kst_episode, + 'sheet': sheet_name, + 'row_index': idx, + 'kst_data': { + kst_title_col: row[kst_title_col], + kst_episode_col: row[kst_episode_col] + } + }) + + # Extract Coordi data + if coordi_title_col and coordi_episode_col: + coordi_title = str(row.get(coordi_title_col, '')).strip() + coordi_episode = str(row.get(coordi_episode_col, '')).strip() + + # Check if this row has valid Coordi data + has_coordi_data = ( + coordi_title and coordi_title != 'nan' and + coordi_episode and coordi_episode != 'nan' and + pd.notna(row[coordi_title_col]) and pd.notna(row[coordi_episode_col]) + ) + + if has_coordi_data: + item = ComparisonItem(coordi_title, coordi_episode, sheet_name, idx) + coordi_items.add(item) + coordi_details.append({ + 'title': coordi_title, + 'episode': coordi_episode, + 'sheet': sheet_name, + 'row_index': idx, + 'coordi_data': { + coordi_title_col: row[coordi_title_col], + coordi_episode_col: row[coordi_episode_col] + } + }) + + self.kst_items = kst_items + self.coordi_items = coordi_items + + return { + 'kst_items': kst_items, + 'coordi_items': coordi_items, + 'kst_details': kst_details, + 'coordi_details': coordi_details + } + + def categorize_mismatches(self) -> Dict[str, Any]: + """Categorize data into KST-only, Coordi-only, and matched items""" + if not self.kst_items or not self.coordi_items: + self.extract_kst_coordi_items() + + # Find overlaps and differences + matched_items = self.kst_items.intersection(self.coordi_items) + kst_only_items = self.kst_items - self.coordi_items + coordi_only_items = self.coordi_items - self.kst_items + + # Find duplicates within each dataset + kst_duplicates = self._find_duplicates_in_set(self.kst_items) + coordi_duplicates = self._find_duplicates_in_set(self.coordi_items) + + categorization = { + 'matched_items': list(matched_items), + 'kst_only_items': list(kst_only_items), + 'coordi_only_items': list(coordi_only_items), + 'kst_duplicates': kst_duplicates, + 'coordi_duplicates': coordi_duplicates, + 'counts': { + 'total_kst': len(self.kst_items), + 'total_coordi': len(self.coordi_items), + 'matched': len(matched_items), + 'kst_only': len(kst_only_items), + 'coordi_only': len(coordi_only_items), + 'kst_duplicates_count': len(kst_duplicates), + 'coordi_duplicates_count': len(coordi_duplicates) + } + } + + # Calculate reconciled counts (after removing mismatches) + reconciled_kst_count = len(matched_items) + reconciled_coordi_count = len(matched_items) + + categorization['reconciliation'] = { + 'original_kst_count': len(self.kst_items), + 'original_coordi_count': len(self.coordi_items), + 'reconciled_kst_count': reconciled_kst_count, + 'reconciled_coordi_count': reconciled_coordi_count, + 'counts_match_after_reconciliation': reconciled_kst_count == reconciled_coordi_count, + 'items_to_exclude_from_kst': len(kst_only_items) + len(kst_duplicates), + 'items_to_exclude_from_coordi': len(coordi_only_items) + len(coordi_duplicates) + } + + return categorization + + def _find_duplicates_in_set(self, items_set: Set[ComparisonItem]) -> List[ComparisonItem]: + """Find duplicate items within a dataset""" + # Convert to list to check for duplicates + items_list = list(items_set) + seen = set() + duplicates = [] + + for item in items_list: + key = (item.title, item.episode) + if key in seen: + duplicates.append(item) + else: + seen.add(key) + + return duplicates + + def generate_mismatch_details(self) -> Dict[str, List[Dict]]: + """Generate detailed information about each type of mismatch with reasons""" + categorization = self.categorize_mismatches() + + mismatch_details = { + 'kst_only': [], + 'coordi_only': [], + 'kst_duplicates': [], + 'coordi_duplicates': [] + } + + # KST-only items + for item in categorization['kst_only_items']: + mismatch_details['kst_only'].append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row_index': item.row_index, + 'reason': 'Item exists in KST data but not in Coordi data', + 'mismatch_type': 'KST_ONLY' + }) + + # Coordi-only items + for item in categorization['coordi_only_items']: + mismatch_details['coordi_only'].append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row_index': item.row_index, + 'reason': 'Item exists in Coordi data but not in KST data', + 'mismatch_type': 'COORDI_ONLY' + }) + + # KST duplicates + for item in categorization['kst_duplicates']: + mismatch_details['kst_duplicates'].append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row_index': item.row_index, + 'reason': 'Duplicate entry in KST data', + 'mismatch_type': 'KST_DUPLICATE' + }) + + # Coordi duplicates + for item in categorization['coordi_duplicates']: + mismatch_details['coordi_duplicates'].append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row_index': item.row_index, + 'reason': 'Duplicate entry in Coordi data', + 'mismatch_type': 'COORDI_DUPLICATE' + }) + + return mismatch_details + + def get_comparison_summary(self, sheet_filter: str = None) -> Dict[str, Any]: + """Get a comprehensive summary of the comparison, optionally filtered by sheet""" + categorization = self.categorize_mismatches() + mismatch_details = self.generate_mismatch_details() + grouped_data = self.group_by_title() + + # Get sheet names for filtering options + sheet_names = list(self.data.keys()) if self.data else [] + + # Apply sheet filtering if specified + if sheet_filter and sheet_filter != 'All Sheets': + mismatch_details = self.filter_by_sheet(mismatch_details, sheet_filter) + grouped_data = self.filter_grouped_data_by_sheet(grouped_data, sheet_filter) + + # Recalculate counts for filtered data + filtered_counts = self.calculate_filtered_counts(mismatch_details) + else: + filtered_counts = { + 'kst_total': categorization['counts']['total_kst'], + 'coordi_total': categorization['counts']['total_coordi'], + 'matched': categorization['counts']['matched'], + 'kst_only_count': categorization['counts']['kst_only'], + 'coordi_only_count': categorization['counts']['coordi_only'], + 'kst_duplicates_count': categorization['counts']['kst_duplicates_count'], + 'coordi_duplicates_count': categorization['counts']['coordi_duplicates_count'] + } + + summary = { + 'sheet_names': sheet_names, + 'current_sheet_filter': sheet_filter or 'All Sheets', + 'original_counts': { + 'kst_total': filtered_counts['kst_total'], + 'coordi_total': filtered_counts['coordi_total'] + }, + 'matched_items_count': filtered_counts['matched'], + 'mismatches': { + 'kst_only_count': filtered_counts['kst_only_count'], + 'coordi_only_count': filtered_counts['coordi_only_count'], + 'kst_duplicates_count': filtered_counts['kst_duplicates_count'], + 'coordi_duplicates_count': filtered_counts['coordi_duplicates_count'] + }, + 'reconciliation': categorization['reconciliation'], + 'mismatch_details': mismatch_details, + 'grouped_by_title': grouped_data + } + + return summary + + def filter_by_sheet(self, mismatch_details: Dict[str, List], sheet_filter: str) -> Dict[str, List]: + """Filter mismatch details by specific sheet""" + filtered = {} + for category, items in mismatch_details.items(): + filtered[category] = [item for item in items if item.get('sheet') == sheet_filter] + return filtered + + def filter_grouped_data_by_sheet(self, grouped_data: Dict, sheet_filter: str) -> Dict: + """Filter grouped data by specific sheet""" + filtered = { + 'kst_only_by_title': {}, + 'coordi_only_by_title': {}, + 'matched_by_title': {}, + 'title_summaries': {} + } + + # Filter each category + for category in ['kst_only_by_title', 'coordi_only_by_title', 'matched_by_title']: + for title, items in grouped_data[category].items(): + filtered_items = [item for item in items if item.get('sheet') == sheet_filter] + if filtered_items: + filtered[category][title] = filtered_items + + # Recalculate title summaries for filtered data + all_titles = set() + all_titles.update(filtered['kst_only_by_title'].keys()) + all_titles.update(filtered['coordi_only_by_title'].keys()) + all_titles.update(filtered['matched_by_title'].keys()) + + for title in all_titles: + kst_only_count = len(filtered['kst_only_by_title'].get(title, [])) + coordi_only_count = len(filtered['coordi_only_by_title'].get(title, [])) + matched_count = len(filtered['matched_by_title'].get(title, [])) + total_episodes = kst_only_count + coordi_only_count + matched_count + + filtered['title_summaries'][title] = { + 'total_episodes': total_episodes, + 'matched_count': matched_count, + 'kst_only_count': kst_only_count, + 'coordi_only_count': coordi_only_count, + 'match_percentage': round((matched_count / total_episodes * 100) if total_episodes > 0 else 0, 1), + 'has_mismatches': kst_only_count > 0 or coordi_only_count > 0 + } + + return filtered + + def calculate_filtered_counts(self, filtered_mismatch_details: Dict[str, List]) -> Dict[str, int]: + """Calculate counts for filtered data""" + return { + 'kst_total': len(filtered_mismatch_details['kst_only']) + len(filtered_mismatch_details['kst_duplicates']), + 'coordi_total': len(filtered_mismatch_details['coordi_only']) + len(filtered_mismatch_details['coordi_duplicates']), + 'matched': 0, # Will be calculated from matched data separately + 'kst_only_count': len(filtered_mismatch_details['kst_only']), + 'coordi_only_count': len(filtered_mismatch_details['coordi_only']), + 'kst_duplicates_count': len(filtered_mismatch_details['kst_duplicates']), + 'coordi_duplicates_count': len(filtered_mismatch_details['coordi_duplicates']) + } + + def group_by_title(self) -> Dict[str, Any]: + """Group mismatches and matches by KR title""" + from collections import defaultdict + + grouped = { + 'kst_only_by_title': defaultdict(list), + 'coordi_only_by_title': defaultdict(list), + 'matched_by_title': defaultdict(list), + 'title_summaries': {} + } + + # Get mismatch details + mismatch_details = self.generate_mismatch_details() + + # Group KST only items by title + for item in mismatch_details['kst_only']: + title = item['title'] + grouped['kst_only_by_title'][title].append(item) + + # Group Coordi only items by title + for item in mismatch_details['coordi_only']: + title = item['title'] + grouped['coordi_only_by_title'][title].append(item) + + # Group matched items by title + if hasattr(self, 'kst_items') and hasattr(self, 'coordi_items'): + categorization = self.categorize_mismatches() + matched_items = categorization['matched_items'] + + for item in matched_items: + title = item.title + grouped['matched_by_title'][title].append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row_index': item.row_index, + 'reason': 'Perfect match' + }) + + # Create summary for each title + all_titles = set() + all_titles.update(grouped['kst_only_by_title'].keys()) + all_titles.update(grouped['coordi_only_by_title'].keys()) + all_titles.update(grouped['matched_by_title'].keys()) + + for title in all_titles: + kst_only_count = len(grouped['kst_only_by_title'][title]) + coordi_only_count = len(grouped['coordi_only_by_title'][title]) + matched_count = len(grouped['matched_by_title'][title]) + total_episodes = kst_only_count + coordi_only_count + matched_count + + grouped['title_summaries'][title] = { + 'total_episodes': total_episodes, + 'matched_count': matched_count, + 'kst_only_count': kst_only_count, + 'coordi_only_count': coordi_only_count, + 'match_percentage': round((matched_count / total_episodes * 100) if total_episodes > 0 else 0, 1), + 'has_mismatches': kst_only_count > 0 or coordi_only_count > 0 + } + + # Convert defaultdicts to regular dicts for JSON serialization + grouped['kst_only_by_title'] = dict(grouped['kst_only_by_title']) + grouped['coordi_only_by_title'] = dict(grouped['coordi_only_by_title']) + grouped['matched_by_title'] = dict(grouped['matched_by_title']) + + return grouped + + def print_comparison_summary(self): + """Print a formatted summary of the comparison""" + summary = self.get_comparison_summary() + + print("=" * 80) + print("KST vs COORDI COMPARISON SUMMARY") + print("=" * 80) + + print(f"Original Counts:") + print(f" KST Total: {summary['original_counts']['kst_total']}") + print(f" Coordi Total: {summary['original_counts']['coordi_total']}") + print() + + print(f"Matched Items: {summary['matched_items_count']}") + print() + + print(f"Mismatches:") + print(f" KST Only: {summary['mismatches']['kst_only_count']}") + print(f" Coordi Only: {summary['mismatches']['coordi_only_count']}") + print(f" KST Duplicates: {summary['mismatches']['kst_duplicates_count']}") + print(f" Coordi Duplicates: {summary['mismatches']['coordi_duplicates_count']}") + print() + + print(f"Reconciliation:") + reconciliation = summary['reconciliation'] + print(f" After excluding mismatches:") + print(f" KST Count: {reconciliation['reconciled_kst_count']}") + print(f" Coordi Count: {reconciliation['reconciled_coordi_count']}") + print(f" Counts Match: {reconciliation['counts_match_after_reconciliation']}") + print() + + # Show sample mismatches + for mismatch_type, details in summary['mismatch_details'].items(): + if details: + print(f"{mismatch_type.upper()} (showing first 3):") + for i, item in enumerate(details[:3]): + print(f" {i+1}. {item['title']} - Episode {item['episode']} ({item['reason']})") + if len(details) > 3: + print(f" ... and {len(details) - 3} more") + print() + +if __name__ == "__main__": + # Test the comparator + comparator = KSTCoordiComparator("data/sample-data.xlsx") + + if comparator.load_data(): + print("Data loaded successfully!") + comparator.print_comparison_summary() + else: + print("Failed to load data!") \ No newline at end of file diff --git a/gui_app.py b/gui_app.py new file mode 100644 index 0000000..b3dd952 --- /dev/null +++ b/gui_app.py @@ -0,0 +1,319 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import pandas as pd +from pathlib import Path +from data_comparator import KSTCoordiComparator + +class DataComparisonGUI: + def __init__(self, root): + self.root = root + self.root.title("KST vs Coordi Data Comparison Tool") + self.root.geometry("1200x800") + + self.comparator = None + self.comparison_data = None + + self.setup_ui() + + def setup_ui(self): + # Main container + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + main_frame.rowconfigure(2, weight=1) + + # Title + title_label = ttk.Label(main_frame, text="KST vs Coordi Data Comparison", + font=("Arial", 16, "bold")) + title_label.grid(row=0, column=0, columnspan=3, pady=(0, 20)) + + # File selection frame + file_frame = ttk.LabelFrame(main_frame, text="File Selection", padding="10") + file_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + file_frame.columnconfigure(1, weight=1) + + ttk.Label(file_frame, text="Excel File:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) + + self.file_path_var = tk.StringVar(value="data/sample-data.xlsx") + self.file_entry = ttk.Entry(file_frame, textvariable=self.file_path_var, width=50) + self.file_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) + + browse_btn = ttk.Button(file_frame, text="Browse", command=self.browse_file) + browse_btn.grid(row=0, column=2) + + analyze_btn = ttk.Button(file_frame, text="Analyze Data", command=self.analyze_data) + analyze_btn.grid(row=0, column=3, padx=(10, 0)) + + # Results notebook (tabs) + self.notebook = ttk.Notebook(main_frame) + self.notebook.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Create tabs + self.create_summary_tab() + self.create_matched_tab() + self.create_kst_only_tab() + self.create_coordi_only_tab() + + # Status bar + self.status_var = tk.StringVar(value="Ready - Select an Excel file and click 'Analyze Data'") + status_bar = ttk.Label(main_frame, textvariable=self.status_var, relief=tk.SUNKEN) + status_bar.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(10, 0)) + + def create_summary_tab(self): + # Summary tab + summary_frame = ttk.Frame(self.notebook) + self.notebook.add(summary_frame, text="Summary") + + # Configure grid + summary_frame.columnconfigure(0, weight=1) + summary_frame.rowconfigure(1, weight=1) + + # Summary text widget + summary_text_frame = ttk.LabelFrame(summary_frame, text="Comparison Summary", padding="10") + summary_text_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=10, pady=10) + summary_text_frame.columnconfigure(0, weight=1) + summary_text_frame.rowconfigure(0, weight=1) + + self.summary_text = tk.Text(summary_text_frame, wrap=tk.WORD, height=15) + summary_scrollbar = ttk.Scrollbar(summary_text_frame, orient=tk.VERTICAL, command=self.summary_text.yview) + self.summary_text.configure(yscrollcommand=summary_scrollbar.set) + + self.summary_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + summary_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + + # Reconciliation info + reconcile_frame = ttk.LabelFrame(summary_frame, text="Reconciliation Results", padding="10") + reconcile_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), padx=10, pady=(0, 10)) + + self.reconcile_text = tk.Text(reconcile_frame, wrap=tk.WORD, height=8) + reconcile_scrollbar = ttk.Scrollbar(reconcile_frame, orient=tk.VERTICAL, command=self.reconcile_text.yview) + self.reconcile_text.configure(yscrollcommand=reconcile_scrollbar.set) + + self.reconcile_text.grid(row=0, column=0, sticky=(tk.W, tk.E)) + reconcile_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + + reconcile_frame.columnconfigure(0, weight=1) + + def create_matched_tab(self): + matched_frame = ttk.Frame(self.notebook) + self.notebook.add(matched_frame, text="Matched Items") + + self.create_data_table(matched_frame, "matched") + + def create_kst_only_tab(self): + kst_frame = ttk.Frame(self.notebook) + self.notebook.add(kst_frame, text="KST Only") + + self.create_data_table(kst_frame, "kst_only") + + def create_coordi_only_tab(self): + coordi_frame = ttk.Frame(self.notebook) + self.notebook.add(coordi_frame, text="Coordi Only") + + self.create_data_table(coordi_frame, "coordi_only") + + def create_data_table(self, parent, table_type): + # Configure grid + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + # Info label + info_frame = ttk.Frame(parent) + info_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=10, pady=10) + info_frame.columnconfigure(1, weight=1) + + count_label = ttk.Label(info_frame, text="Count:") + count_label.grid(row=0, column=0, padx=(0, 10)) + + count_var = tk.StringVar(value="0") + setattr(self, f"{table_type}_count_var", count_var) + count_display = ttk.Label(info_frame, textvariable=count_var, font=("Arial", 10, "bold")) + count_display.grid(row=0, column=1, sticky=tk.W) + + # Table frame + table_frame = ttk.Frame(parent) + table_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=10, pady=(0, 10)) + table_frame.columnconfigure(0, weight=1) + table_frame.rowconfigure(0, weight=1) + + # Create treeview + columns = ("Title", "Episode", "Sheet", "Row", "Reason") + tree = ttk.Treeview(table_frame, columns=columns, show="headings", height=20) + + # Configure columns + tree.heading("Title", text="Title") + tree.heading("Episode", text="Episode") + tree.heading("Sheet", text="Sheet") + tree.heading("Row", text="Row") + tree.heading("Reason", text="Reason") + + tree.column("Title", width=300) + tree.column("Episode", width=100) + tree.column("Sheet", width=120) + tree.column("Row", width=80) + tree.column("Reason", width=300) + + # Scrollbars + v_scrollbar = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=tree.yview) + h_scrollbar = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=tree.xview) + tree.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) + + tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + v_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + h_scrollbar.grid(row=1, column=0, sticky=(tk.W, tk.E)) + + # Store tree widget + setattr(self, f"{table_type}_tree", tree) + + def browse_file(self): + file_path = filedialog.askopenfilename( + title="Select Excel File", + filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")] + ) + if file_path: + self.file_path_var.set(file_path) + + def analyze_data(self): + file_path = self.file_path_var.get().strip() + + if not file_path: + messagebox.showerror("Error", "Please select an Excel file") + return + + if not Path(file_path).exists(): + messagebox.showerror("Error", f"File not found: {file_path}") + return + + try: + self.status_var.set("Analyzing data...") + self.root.update() + + # Create comparator and analyze + self.comparator = KSTCoordiComparator(file_path) + if not self.comparator.load_data(): + messagebox.showerror("Error", "Failed to load Excel data") + return + + # Get comparison results + self.comparison_data = self.comparator.get_comparison_summary() + + # Update GUI + self.update_summary() + self.update_data_tables() + + self.status_var.set("Analysis complete!") + + except Exception as e: + messagebox.showerror("Error", f"Analysis failed: {str(e)}") + self.status_var.set("Analysis failed") + + def update_summary(self): + if not self.comparison_data: + return + + # Clear previous content + self.summary_text.delete(1.0, tk.END) + self.reconcile_text.delete(1.0, tk.END) + + data = self.comparison_data + + # Summary text + summary = f"""COMPARISON SUMMARY +{'='*50} + +Original Counts: + KST Total: {data['original_counts']['kst_total']:,} + Coordi Total: {data['original_counts']['coordi_total']:,} + +Matched Items: {data['matched_items_count']:,} + +Mismatches: + KST Only: {data['mismatches']['kst_only_count']:,} + Coordi Only: {data['mismatches']['coordi_only_count']:,} + KST Duplicates: {data['mismatches']['kst_duplicates_count']:,} + Coordi Duplicates: {data['mismatches']['coordi_duplicates_count']:,} + +Total Mismatches: {data['mismatches']['kst_only_count'] + data['mismatches']['coordi_only_count'] + data['mismatches']['kst_duplicates_count'] + data['mismatches']['coordi_duplicates_count']:,} +""" + + self.summary_text.insert(tk.END, summary) + + # Reconciliation text + reconcile = data['reconciliation'] + reconcile_info = f"""RECONCILIATION RESULTS +{'='*40} + +After excluding mismatches: + KST Count: {reconcile['reconciled_kst_count']:,} + Coordi Count: {reconcile['reconciled_coordi_count']:,} + Counts Match: {'✅ YES' if reconcile['counts_match_after_reconciliation'] else '❌ NO'} + +Items to exclude: + From KST: {reconcile['items_to_exclude_from_kst']:,} + From Coordi: {reconcile['items_to_exclude_from_coordi']:,} + +Final Result: Both datasets will have {reconcile['reconciled_kst_count']:,} matching items after reconciliation. +""" + + self.reconcile_text.insert(tk.END, reconcile_info) + + def update_data_tables(self): + if not self.comparison_data: + return + + mismatches = self.comparison_data['mismatch_details'] + + # Update matched items (create from intersection) + matched_count = self.comparison_data['matched_items_count'] + self.matched_count_var.set(f"{matched_count:,}") + + # Clear matched tree + for item in self.matched_tree.get_children(): + self.matched_tree.delete(item) + + # Add matched items (we'll show the first few as examples) + if self.comparator: + categorization = self.comparator.categorize_mismatches() + matched_items = categorization['matched_items'] + for i, item in enumerate(list(matched_items)[:100]): # Show first 100 + self.matched_tree.insert("", tk.END, values=( + item.title, item.episode, item.source_sheet, item.row_index + 1, "Perfect match" + )) + + # Update KST only + kst_only = mismatches['kst_only'] + self.kst_only_count_var.set(f"{len(kst_only):,}") + + for item in self.kst_only_tree.get_children(): + self.kst_only_tree.delete(item) + + for mismatch in kst_only: + self.kst_only_tree.insert("", tk.END, values=( + mismatch['title'], mismatch['episode'], mismatch['sheet'], + mismatch['row_index'] + 1, mismatch['reason'] + )) + + # Update Coordi only + coordi_only = mismatches['coordi_only'] + self.coordi_only_count_var.set(f"{len(coordi_only):,}") + + for item in self.coordi_only_tree.get_children(): + self.coordi_only_tree.delete(item) + + for mismatch in coordi_only: + self.coordi_only_tree.insert("", tk.END, values=( + mismatch['title'], mismatch['episode'], mismatch['sheet'], + mismatch['row_index'] + 1, mismatch['reason'] + )) + +def main(): + root = tk.Tk() + app = DataComparisonGUI(root) + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9947ae7 --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +from web_gui import main as web_gui_main + +def main(): + print("Launching KST vs Coordi Data Comparison Web GUI...") + web_gui_main() + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..14d071b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "data-comparison" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pandas>=2.0.0", + "openpyxl>=3.1.0", + "flask>=2.3.0", + "requests>=2.25.0" +] diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f916ec0 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,515 @@ + + + + + + KST vs Coordi Data Comparison + + + +

+

KST vs Coordi Data Comparison Tool

+ +
+
+ + + +
+
+ + + +
+
+ + +
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/test_grouping.py b/test_grouping.py new file mode 100644 index 0000000..1716daf --- /dev/null +++ b/test_grouping.py @@ -0,0 +1,28 @@ +from data_comparator import KSTCoordiComparator + +def test_grouping(): + comparator = KSTCoordiComparator('data/sample-data.xlsx') + if comparator.load_data(): + summary = comparator.get_comparison_summary() + + print("=== GROUPED BY TITLE TEST ===") + print(f"Total unique titles: {len(summary['grouped_by_title']['title_summaries'])}") + print() + + # Show top 5 titles with worst match percentages + sorted_titles = sorted( + summary['grouped_by_title']['title_summaries'].items(), + key=lambda x: x[1]['match_percentage'] + ) + + print("Top 5 titles needing attention (worst match %):") + for i, (title, data) in enumerate(sorted_titles[:5]): + print(f"{i+1}. {title}") + print(f" Total Episodes: {data['total_episodes']}") + print(f" Matched: {data['matched_count']} ({data['match_percentage']}%)") + print(f" KST Only: {data['kst_only_count']}") + print(f" Coordi Only: {data['coordi_only_count']}") + print() + +if __name__ == "__main__": + test_grouping() \ No newline at end of file diff --git a/test_sheet_filtering.py b/test_sheet_filtering.py new file mode 100644 index 0000000..63109a6 --- /dev/null +++ b/test_sheet_filtering.py @@ -0,0 +1,38 @@ +from data_comparator import KSTCoordiComparator + +def test_sheet_filtering(): + comparator = KSTCoordiComparator('data/sample-data.xlsx') + if comparator.load_data(): + print("=== SHEET FILTERING TEST ===") + + # Test All Sheets + summary_all = comparator.get_comparison_summary() + print(f"All Sheets:") + print(f" Available sheets: {summary_all['sheet_names']}") + print(f" Matched items: {summary_all['matched_items_count']}") + print(f" KST only: {summary_all['mismatches']['kst_only_count']}") + print(f" Coordi only: {summary_all['mismatches']['coordi_only_count']}") + print() + + # Test TH URGENT only + summary_th = comparator.get_comparison_summary('TH URGENT') + print(f"TH URGENT only:") + print(f" Matched items: {summary_th['matched_items_count']}") + print(f" KST only: {summary_th['mismatches']['kst_only_count']}") + print(f" Coordi only: {summary_th['mismatches']['coordi_only_count']}") + print() + + # Test US URGENT only + summary_us = comparator.get_comparison_summary('US URGENT') + print(f"US URGENT only:") + print(f" Matched items: {summary_us['matched_items_count']}") + print(f" KST only: {summary_us['mismatches']['kst_only_count']}") + print(f" Coordi only: {summary_us['mismatches']['coordi_only_count']}") + print() + + print("✓ Sheet filtering functionality working!") + print("✓ Web GUI ready at http://localhost:8080") + print("✓ Dropdown will show: All Sheets, TH URGENT, US URGENT") + +if __name__ == "__main__": + test_sheet_filtering() \ No newline at end of file diff --git a/test_simplified_gui.py b/test_simplified_gui.py new file mode 100644 index 0000000..8188dde --- /dev/null +++ b/test_simplified_gui.py @@ -0,0 +1,35 @@ +from data_comparator import KSTCoordiComparator + +def test_simplified_interface(): + comparator = KSTCoordiComparator('data/sample-data.xlsx') + if comparator.load_data(): + summary = comparator.get_comparison_summary() + + print("=== SIMPLIFIED INTERFACE TEST ===") + print(f"Matched items (Summary tab): {summary['matched_items_count']}") + + total_different = (summary['mismatches']['kst_only_count'] + + summary['mismatches']['coordi_only_count'] + + summary['mismatches']['kst_duplicates_count'] + + summary['mismatches']['coordi_duplicates_count']) + print(f"Different items (Different tab): {total_different}") + print() + + print("Sample items from Different tab (first 5):") + + # Show KST-only items + kst_only = summary['mismatch_details']['kst_only'][:3] + for item in kst_only: + print(f"KST: {item['title']} - Episode {item['episode']} | Coordi: (empty) | Reason: Only appears in KST") + + # Show Coordi-only items + coordi_only = summary['mismatch_details']['coordi_only'][:2] + for item in coordi_only: + print(f"KST: (empty) | Coordi: {item['title']} - Episode {item['episode']} | Reason: Only appears in Coordi") + + print(f"\n✅ Interface ready at http://localhost:8080") + print("✅ Two tabs: Summary (matched items) and Different (mismatches)") + print("✅ Korean title + episode sorting implemented") + +if __name__ == "__main__": + test_simplified_interface() \ No newline at end of file diff --git a/test_upload.py b/test_upload.py new file mode 100644 index 0000000..4e422fa --- /dev/null +++ b/test_upload.py @@ -0,0 +1,58 @@ +import requests +import os + +def test_file_upload(): + # Test the upload endpoint + file_path = 'data/sample-data.xlsx' + + if not os.path.exists(file_path): + print("Error: Sample file not found") + return + + print("=== FILE UPLOAD TEST ===") + print(f"Testing upload of: {file_path}") + + # Test upload endpoint + with open(file_path, 'rb') as f: + files = {'file': f} + try: + response = requests.post('http://localhost:8080/upload', files=files, timeout=30) + + if response.status_code == 200: + data = response.json() + if data.get('success'): + print("✓ File upload successful!") + print(f" Uploaded filename: {data['filename']}") + print(f" Server file path: {data['file_path']}") + + # Test analysis of uploaded file + print("✓ Testing analysis of uploaded file...") + analyze_response = requests.post('http://localhost:8080/analyze', + json={'file_path': data['file_path']}, + timeout=30) + + if analyze_response.status_code == 200: + analyze_data = analyze_response.json() + if analyze_data.get('success'): + print("✓ Analysis of uploaded file successful!") + results = analyze_data['results'] + print(f" Sheets found: {results['sheet_names']}") + print(f" Matched items: {results['matched_items_count']}") + print(f" Different items: {results['mismatches']['kst_only_count'] + results['mismatches']['coordi_only_count']}") + else: + print(f"✗ Analysis failed: {analyze_data.get('error')}") + else: + print(f"✗ Analysis request failed: {analyze_response.status_code}") + + else: + print(f"✗ Upload failed: {data.get('error')}") + else: + print(f"✗ Upload request failed: {response.status_code}") + print(f"Response: {response.text}") + + except requests.exceptions.RequestException as e: + print(f"✗ Request failed: {e}") + print("Make sure the Flask server is running at http://localhost:8080") + +if __name__ == "__main__": + test_file_upload() \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8e01476 --- /dev/null +++ b/uv.lock @@ -0,0 +1,342 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "data-comparison" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=2.3.0" }, + { name = "openpyxl", specifier = ">=3.1.0" }, + { name = "pandas", specifier = ">=2.0.0" }, + { name = "requests", specifier = ">=2.25.0" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/6f/75aa71f8a14267117adeeed5d21b204770189c0a0025acbdc03c337b28fc/pandas-2.3.1.tar.gz", hash = "sha256:0a95b9ac964fe83ce317827f80304d37388ea77616b1425f0ae41c9d2d0d7bb2", size = 4487493, upload-time = "2025-07-07T19:20:04.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/ed/ff0a67a2c5505e1854e6715586ac6693dd860fbf52ef9f81edee200266e7/pandas-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9026bd4a80108fac2239294a15ef9003c4ee191a0f64b90f170b40cfb7cf2d22", size = 11531393, upload-time = "2025-07-07T19:19:12.245Z" }, + { url = "https://files.pythonhosted.org/packages/c7/db/d8f24a7cc9fb0972adab0cc80b6817e8bef888cfd0024eeb5a21c0bb5c4a/pandas-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6de8547d4fdb12421e2d047a2c446c623ff4c11f47fddb6b9169eb98ffba485a", size = 10668750, upload-time = "2025-07-07T19:19:14.612Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b0/80f6ec783313f1e2356b28b4fd8d2148c378370045da918c73145e6aab50/pandas-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782647ddc63c83133b2506912cc6b108140a38a37292102aaa19c81c83db2928", size = 11342004, upload-time = "2025-07-07T19:19:16.857Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e2/20a317688435470872885e7fc8f95109ae9683dec7c50be29b56911515a5/pandas-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba6aff74075311fc88504b1db890187a3cd0f887a5b10f5525f8e2ef55bfdb9", size = 12050869, upload-time = "2025-07-07T19:19:19.265Z" }, + { url = "https://files.pythonhosted.org/packages/55/79/20d746b0a96c67203a5bee5fb4e00ac49c3e8009a39e1f78de264ecc5729/pandas-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5635178b387bd2ba4ac040f82bc2ef6e6b500483975c4ebacd34bec945fda12", size = 12750218, upload-time = "2025-07-07T19:19:21.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0f/145c8b41e48dbf03dd18fdd7f24f8ba95b8254a97a3379048378f33e7838/pandas-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f3bf5ec947526106399a9e1d26d40ee2b259c66422efdf4de63c848492d91bb", size = 13416763, upload-time = "2025-07-07T19:19:23.939Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c0/54415af59db5cdd86a3d3bf79863e8cc3fa9ed265f0745254061ac09d5f2/pandas-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:1c78cf43c8fde236342a1cb2c34bcff89564a7bfed7e474ed2fffa6aed03a956", size = 10987482, upload-time = "2025-07-07T19:19:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/2fd2e400073a1230e13b8cd604c9bc95d9e3b962e5d44088ead2e8f0cfec/pandas-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8dfc17328e8da77be3cf9f47509e5637ba8f137148ed0e9b5241e1baf526e20a", size = 12029159, upload-time = "2025-07-07T19:19:26.362Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0a/d84fd79b0293b7ef88c760d7dca69828d867c89b6d9bc52d6a27e4d87316/pandas-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ec6c851509364c59a5344458ab935e6451b31b818be467eb24b0fe89bd05b6b9", size = 11393287, upload-time = "2025-07-07T19:19:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/50/ae/ff885d2b6e88f3c7520bb74ba319268b42f05d7e583b5dded9837da2723f/pandas-2.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:911580460fc4884d9b05254b38a6bfadddfcc6aaef856fb5859e7ca202e45275", size = 11309381, upload-time = "2025-07-07T19:19:31.436Z" }, + { url = "https://files.pythonhosted.org/packages/85/86/1fa345fc17caf5d7780d2699985c03dbe186c68fee00b526813939062bb0/pandas-2.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f4d6feeba91744872a600e6edbbd5b033005b431d5ae8379abee5bcfa479fab", size = 11883998, upload-time = "2025-07-07T19:19:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/81/aa/e58541a49b5e6310d89474333e994ee57fea97c8aaa8fc7f00b873059bbf/pandas-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fe37e757f462d31a9cd7580236a82f353f5713a80e059a29753cf938c6775d96", size = 12704705, upload-time = "2025-07-07T19:19:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f9/07086f5b0f2a19872554abeea7658200824f5835c58a106fa8f2ae96a46c/pandas-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5db9637dbc24b631ff3707269ae4559bce4b7fd75c1c4d7e13f40edc42df4444", size = 13189044, upload-time = "2025-07-07T19:19:39.999Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] diff --git a/web_gui.py b/web_gui.py new file mode 100644 index 0000000..eabea27 --- /dev/null +++ b/web_gui.py @@ -0,0 +1,637 @@ +from flask import Flask, render_template, request, jsonify +import json +import os +import tempfile +from pathlib import Path +from werkzeug.utils import secure_filename +from data_comparator import KSTCoordiComparator + +app = Flask(__name__) +app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max file size +app.config['UPLOAD_FOLDER'] = tempfile.gettempdir() + +# Global variable to store comparison results +comparison_results = None +comparator_instance = None + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/analyze', methods=['POST']) +def analyze_data(): + global comparison_results, comparator_instance + + try: + file_path = request.json.get('file_path', 'data/sample-data.xlsx') + sheet_filter = request.json.get('sheet_filter', None) + + if not Path(file_path).exists(): + return jsonify({'error': f'File not found: {file_path}'}), 400 + + # Create comparator and analyze + comparator_instance = KSTCoordiComparator(file_path) + if not comparator_instance.load_data(): + return jsonify({'error': 'Failed to load Excel data'}), 500 + + # Get comparison results with optional sheet filtering + comparison_results = comparator_instance.get_comparison_summary(sheet_filter) + + # Get matched items for display + categorization = comparator_instance.categorize_mismatches() + matched_items = list(categorization['matched_items']) + + # Filter matched items by sheet if specified + if sheet_filter and sheet_filter != 'All Sheets': + matched_items = [item for item in matched_items if item.source_sheet == sheet_filter] + + # Format matched items for JSON (limit to first 500 for performance) + matched_data = [] + for item in matched_items[:500]: + matched_data.append({ + 'title': item.title, + 'episode': item.episode, + 'sheet': item.source_sheet, + 'row': item.row_index + 1, + 'reason': 'Perfect match' + }) + + # Add matched data to results + comparison_results['matched_data'] = matched_data + comparison_results['matched_items_count'] = len(matched_items) # Update count for filtered data + + return jsonify({ + 'success': True, + 'results': comparison_results + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/upload', methods=['POST']) +def upload_file(): + try: + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if file and file.filename.lower().endswith(('.xlsx', '.xls')): + # Save uploaded file + filename = secure_filename(file.filename) + file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(file_path) + + return jsonify({ + 'success': True, + 'file_path': file_path, + 'filename': filename + }) + else: + return jsonify({'error': 'Please upload an Excel file (.xlsx or .xls)'}), 400 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/get_results') +def get_results(): + if comparison_results is None: + return jsonify({'error': 'No analysis results available'}), 404 + return jsonify(comparison_results) + +def create_templates_dir(): + """Create templates directory and HTML file""" + templates_dir = Path('templates') + templates_dir.mkdir(exist_ok=True) + + html_content = ''' + + + + + KST vs Coordi Data Comparison + + + +
+

KST vs Coordi Data Comparison Tool

+ +
+
+ + + +
+
+ + + +
+
+ + +
+
+
+ + +
+ + + +''' + + html_file = templates_dir / 'index.html' + html_file.write_text(html_content) + +def main(): + # Create templates directory and HTML file + create_templates_dir() + + print("Starting web-based GUI...") + print("Open your browser and go to: http://localhost:8080") + app.run(debug=True, host='0.0.0.0', port=8080) + +if __name__ == "__main__": + main() \ No newline at end of file