@mseep/csv-editor 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +53 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/workflows/deploy-docs.yml +62 -0
- package/.github/workflows/publish-github.yml +52 -0
- package/.github/workflows/publish.yml +44 -0
- package/.github/workflows/test.yml +32 -0
- package/.pre-commit-config.yaml +157 -0
- package/ALTERNATIVE_PUBLISHING.md +175 -0
- package/ARCHITECTURE.md +1011 -0
- package/CHANGELOG.md +99 -0
- package/CODE_OF_CONDUCT.md +41 -0
- package/CONTRIBUTING.md +427 -0
- package/Dockerfile +22 -0
- package/LICENSE +21 -0
- package/MCP_CONFIG.md +505 -0
- package/PUBLISHING.md +210 -0
- package/README.md +400 -0
- package/SECURITY.md +61 -0
- package/docs/README.md +41 -0
- package/docs/blog/2019-05-28-first-blog-post.md +12 -0
- package/docs/blog/2019-05-29-long-blog-post.md +44 -0
- package/docs/blog/2021-08-01-mdx-blog-post.mdx +24 -0
- package/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg +0 -0
- package/docs/blog/2021-08-26-welcome/index.md +29 -0
- package/docs/blog/authors.yml +25 -0
- package/docs/blog/tags.yml +19 -0
- package/docs/docs/api/overview.md +183 -0
- package/docs/docs/installation.md +252 -0
- package/docs/docs/intro.md +87 -0
- package/docs/docs/tutorial-basics/_category_.json +8 -0
- package/docs/docs/tutorial-basics/congratulations.md +23 -0
- package/docs/docs/tutorial-basics/create-a-blog-post.md +34 -0
- package/docs/docs/tutorial-basics/create-a-document.md +57 -0
- package/docs/docs/tutorial-basics/create-a-page.md +43 -0
- package/docs/docs/tutorial-basics/deploy-your-site.md +31 -0
- package/docs/docs/tutorial-basics/markdown-features.mdx +152 -0
- package/docs/docs/tutorial-extras/_category_.json +7 -0
- package/docs/docs/tutorial-extras/img/docsVersionDropdown.png +0 -0
- package/docs/docs/tutorial-extras/img/localeDropdown.png +0 -0
- package/docs/docs/tutorial-extras/manage-docs-versions.md +55 -0
- package/docs/docs/tutorial-extras/translate-your-site.md +88 -0
- package/docs/docs/tutorials/quickstart.md +365 -0
- package/docs/docusaurus.config.ts +163 -0
- package/docs/package-lock.json +17493 -0
- package/docs/package.json +48 -0
- package/docs/sidebars.ts +33 -0
- package/docs/src/components/HomepageFeatures/index.tsx +71 -0
- package/docs/src/components/HomepageFeatures/styles.module.css +11 -0
- package/docs/src/css/custom.css +30 -0
- package/docs/src/pages/index.module.css +23 -0
- package/docs/src/pages/index.tsx +44 -0
- package/docs/src/pages/markdown-page.md +7 -0
- package/docs/static/.nojekyll +0 -0
- package/docs/static/img/docusaurus-social-card.jpg +0 -0
- package/docs/static/img/docusaurus.png +0 -0
- package/docs/static/img/favicon.ico +0 -0
- package/docs/static/img/logo.svg +1 -0
- package/docs/static/img/undraw_docusaurus_mountain.svg +171 -0
- package/docs/static/img/undraw_docusaurus_react.svg +170 -0
- package/docs/static/img/undraw_docusaurus_tree.svg +40 -0
- package/docs/tsconfig.json +8 -0
- package/examples/README.md +48 -0
- package/examples/auto_save_demo.py +206 -0
- package/examples/auto_save_overwrite.py +201 -0
- package/examples/basic_usage.py +135 -0
- package/examples/demo.py +139 -0
- package/examples/history_demo.py +317 -0
- package/examples/test_default_autosave.py +124 -0
- package/examples/update_consignee_example.py +179 -0
- package/package.json +51 -0
- package/plans/2026-04-19-fastmcp3-migration-plan.md +1045 -0
- package/pyproject.toml +331 -0
- package/requirements-dev.txt +30 -0
- package/requirements.txt +22 -0
- package/scripts/publish.py +67 -0
- package/smithery.yaml +15 -0
- package/specs/2026-04-19-fastmcp3-migration-design.md +243 -0
- package/src/csv_editor/__init__.py +8 -0
- package/src/csv_editor/models/__init__.py +39 -0
- package/src/csv_editor/models/auto_save.py +246 -0
- package/src/csv_editor/models/csv_session.py +468 -0
- package/src/csv_editor/models/data_models.py +244 -0
- package/src/csv_editor/models/history_manager.py +456 -0
- package/src/csv_editor/prompts/__init__.py +0 -0
- package/src/csv_editor/prompts/data_prompts.py +13 -0
- package/src/csv_editor/resources/__init__.py +0 -0
- package/src/csv_editor/resources/csv_resources.py +22 -0
- package/src/csv_editor/server.py +640 -0
- package/src/csv_editor/tools/__init__.py +5 -0
- package/src/csv_editor/tools/analytics.py +700 -0
- package/src/csv_editor/tools/auto_save_operations.py +235 -0
- package/src/csv_editor/tools/data_operations.py +3 -0
- package/src/csv_editor/tools/history_operations.py +315 -0
- package/src/csv_editor/tools/io_operations.py +431 -0
- package/src/csv_editor/tools/transformations.py +663 -0
- package/src/csv_editor/tools/validation.py +822 -0
- package/src/csv_editor/utils/__init__.py +0 -0
- package/src/csv_editor/utils/validators.py +205 -0
- package/tests/README.md +65 -0
- package/tests/__init__.py +7 -0
- package/tests/conftest.py +50 -0
- package/tests/test_auto_save.py +378 -0
- package/tests/test_basic.py +103 -0
- package/tests/test_integration.py +356 -0
- package/tests/test_server_boot.py +50 -0
- package/tests/test_settings.py +184 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Basic unit tests for CSV Editor."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from src.csv_editor.models import get_session_manager
|
|
6
|
+
from src.csv_editor.utils.validators import sanitize_filename, validate_column_name, validate_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestValidators:
|
|
10
|
+
"""Test validation utilities."""
|
|
11
|
+
|
|
12
|
+
def test_validate_column_name(self):
|
|
13
|
+
"""Test column name validation."""
|
|
14
|
+
# Valid names
|
|
15
|
+
assert validate_column_name("age")[0] == True
|
|
16
|
+
assert validate_column_name("first_name")[0] == True
|
|
17
|
+
assert validate_column_name("_id")[0] == True
|
|
18
|
+
|
|
19
|
+
# Invalid names
|
|
20
|
+
assert validate_column_name("123name")[0] == False
|
|
21
|
+
assert validate_column_name("name-with-dash")[0] == False
|
|
22
|
+
assert validate_column_name("")[0] == False
|
|
23
|
+
|
|
24
|
+
def test_sanitize_filename(self):
|
|
25
|
+
"""Test filename sanitization."""
|
|
26
|
+
assert sanitize_filename("test.csv") == "test.csv"
|
|
27
|
+
assert sanitize_filename("test<>file.csv") == "test__file.csv"
|
|
28
|
+
assert sanitize_filename("../../../etc/passwd") == "passwd"
|
|
29
|
+
|
|
30
|
+
def test_validate_url(self):
|
|
31
|
+
"""Test URL validation."""
|
|
32
|
+
# Valid URLs
|
|
33
|
+
assert validate_url("https://example.com/data.csv")[0] == True
|
|
34
|
+
assert validate_url("http://localhost:8000/file.csv")[0] == True
|
|
35
|
+
|
|
36
|
+
# Invalid URLs
|
|
37
|
+
assert validate_url("ftp://example.com/data.csv")[0] == False
|
|
38
|
+
assert validate_url("not-a-url")[0] == False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestSessionManager:
|
|
42
|
+
"""Test session management."""
|
|
43
|
+
|
|
44
|
+
def test_create_session(self):
|
|
45
|
+
"""Test session creation."""
|
|
46
|
+
manager = get_session_manager()
|
|
47
|
+
session_id = manager.create_session()
|
|
48
|
+
|
|
49
|
+
assert session_id is not None
|
|
50
|
+
assert manager.get_session(session_id) is not None
|
|
51
|
+
|
|
52
|
+
# Cleanup
|
|
53
|
+
manager.remove_session(session_id)
|
|
54
|
+
|
|
55
|
+
def test_session_cleanup(self):
|
|
56
|
+
"""Test session removal."""
|
|
57
|
+
manager = get_session_manager()
|
|
58
|
+
session_id = manager.create_session()
|
|
59
|
+
|
|
60
|
+
# Session should exist
|
|
61
|
+
assert manager.get_session(session_id) is not None
|
|
62
|
+
|
|
63
|
+
# Remove session
|
|
64
|
+
manager.remove_session(session_id)
|
|
65
|
+
|
|
66
|
+
# Session should not exist
|
|
67
|
+
assert manager.get_session(session_id) is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
class TestDataOperations:
|
|
72
|
+
"""Test basic data operations."""
|
|
73
|
+
|
|
74
|
+
async def test_load_csv_from_content(self):
|
|
75
|
+
"""Test loading CSV from string content."""
|
|
76
|
+
from src.csv_editor.tools.io_operations import load_csv_from_content
|
|
77
|
+
|
|
78
|
+
csv_content = """a,b,c
|
|
79
|
+
1,2,3
|
|
80
|
+
4,5,6"""
|
|
81
|
+
|
|
82
|
+
result = await load_csv_from_content(content=csv_content, delimiter=",")
|
|
83
|
+
|
|
84
|
+
assert result["success"] == True
|
|
85
|
+
assert result["rows_affected"] == 2
|
|
86
|
+
assert len(result["columns_affected"]) == 3
|
|
87
|
+
|
|
88
|
+
# Cleanup
|
|
89
|
+
manager = get_session_manager()
|
|
90
|
+
manager.remove_session(result["session_id"])
|
|
91
|
+
|
|
92
|
+
async def test_filter_rows(self, test_session):
|
|
93
|
+
"""Test filtering rows."""
|
|
94
|
+
from src.csv_editor.tools.transformations import filter_rows
|
|
95
|
+
|
|
96
|
+
result = await filter_rows(
|
|
97
|
+
session_id=test_session,
|
|
98
|
+
conditions=[{"column": "price", "operator": ">", "value": 50}],
|
|
99
|
+
mode="and",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert result["success"] == True
|
|
103
|
+
assert result["rows_after"] < result["rows_before"]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Test script for CSV MCP Server
|
|
4
|
+
|
|
5
|
+
This script tests the core functionality of the CSV MCP Server
|
|
6
|
+
without requiring an MCP client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
# Add src to path
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
17
|
+
|
|
18
|
+
from src.csv_editor.models.csv_session import get_session_manager
|
|
19
|
+
from src.csv_editor.tools.analytics import (
|
|
20
|
+
detect_outliers,
|
|
21
|
+
get_correlation_matrix,
|
|
22
|
+
get_statistics,
|
|
23
|
+
group_by_aggregate,
|
|
24
|
+
profile_data,
|
|
25
|
+
)
|
|
26
|
+
from src.csv_editor.tools.io_operations import (
|
|
27
|
+
export_csv,
|
|
28
|
+
get_session_info,
|
|
29
|
+
list_sessions,
|
|
30
|
+
load_csv_from_content,
|
|
31
|
+
)
|
|
32
|
+
from src.csv_editor.tools.transformations import (
|
|
33
|
+
add_column,
|
|
34
|
+
fill_missing_values,
|
|
35
|
+
filter_rows,
|
|
36
|
+
select_columns,
|
|
37
|
+
sort_data,
|
|
38
|
+
)
|
|
39
|
+
from src.csv_editor.tools.validation import check_data_quality, find_anomalies, validate_schema
|
|
40
|
+
|
|
41
|
+
# Test data
|
|
42
|
+
TEST_CSV_CONTENT = """name,age,salary,department,hire_date
|
|
43
|
+
Alice,28,55000,Engineering,2021-01-15
|
|
44
|
+
Bob,35,65000,Engineering,2019-06-01
|
|
45
|
+
Charlie,42,75000,Management,2018-03-20
|
|
46
|
+
Diana,31,58000,Marketing,2020-08-10
|
|
47
|
+
Eve,29,52000,Sales,2021-03-25
|
|
48
|
+
Frank,45,85000,Management,2017-11-30
|
|
49
|
+
Grace,26,48000,Marketing,2022-02-14
|
|
50
|
+
Henry,38,70000,Engineering,2019-09-15
|
|
51
|
+
Iris,33,62000,Sales,2020-05-20
|
|
52
|
+
Jack,41,78000,Management,2018-07-12
|
|
53
|
+
Kate,27,,Marketing,2021-11-01
|
|
54
|
+
Leo,36,68000,Engineering,2019-04-18
|
|
55
|
+
Mia,30,56000,Sales,2020-12-05
|
|
56
|
+
Nathan,44,82000,Management,2017-09-08
|
|
57
|
+
Olivia,25,45000,Marketing,2022-06-30
|
|
58
|
+
Peter,39,72000,Engineering,2018-10-22
|
|
59
|
+
Quinn,32,60000,Sales,2020-03-15
|
|
60
|
+
Rachel,28,54000,Marketing,2021-07-20
|
|
61
|
+
Sam,37,69000,Engineering,2019-02-28
|
|
62
|
+
Tina,34,64000,Sales,2020-01-10
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Colors:
|
|
67
|
+
"""ANSI color codes for terminal output"""
|
|
68
|
+
|
|
69
|
+
HEADER = "\033[95m"
|
|
70
|
+
BLUE = "\033[94m"
|
|
71
|
+
CYAN = "\033[96m"
|
|
72
|
+
GREEN = "\033[92m"
|
|
73
|
+
WARNING = "\033[93m"
|
|
74
|
+
FAIL = "\033[91m"
|
|
75
|
+
ENDC = "\033[0m"
|
|
76
|
+
BOLD = "\033[1m"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_test(name: str):
|
|
80
|
+
"""Print test header"""
|
|
81
|
+
print(f"\n{Colors.HEADER}{Colors.BOLD}Testing: {name}{Colors.ENDC}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def print_success(msg: str):
|
|
85
|
+
"""Print success message"""
|
|
86
|
+
print(f"{Colors.GREEN}✓ {msg}{Colors.ENDC}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def print_error(msg: str):
|
|
90
|
+
"""Print error message"""
|
|
91
|
+
print(f"{Colors.FAIL}✗ {msg}{Colors.ENDC}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_info(msg: str):
|
|
95
|
+
"""Print info message"""
|
|
96
|
+
print(f"{Colors.CYAN}ℹ {msg}{Colors.ENDC}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_data(data: any, indent: int = 2):
|
|
100
|
+
"""Print data with indentation"""
|
|
101
|
+
indent_str = " " * indent
|
|
102
|
+
if isinstance(data, dict):
|
|
103
|
+
for key, value in data.items():
|
|
104
|
+
if isinstance(value, (dict, list)) and len(str(value)) > 50:
|
|
105
|
+
print(
|
|
106
|
+
f"{indent_str}{Colors.BLUE}{key}:{Colors.ENDC} <{type(value).__name__} with {len(value)} items>"
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
print(f"{indent_str}{Colors.BLUE}{key}:{Colors.ENDC} {value}")
|
|
110
|
+
elif isinstance(data, pd.DataFrame):
|
|
111
|
+
print(f"{indent_str}DataFrame shape: {data.shape}")
|
|
112
|
+
print(data.head().to_string().replace("\n", f"\n{indent_str}"))
|
|
113
|
+
else:
|
|
114
|
+
print(f"{indent_str}{data}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_io_operations():
|
|
118
|
+
"""Test I/O operations"""
|
|
119
|
+
print_test("I/O Operations")
|
|
120
|
+
|
|
121
|
+
# Load CSV from content
|
|
122
|
+
result = await load_csv_from_content(content=TEST_CSV_CONTENT, delimiter=",")
|
|
123
|
+
|
|
124
|
+
if result["success"]:
|
|
125
|
+
session_id = result["session_id"]
|
|
126
|
+
print_success(f"Loaded CSV with session ID: {session_id}")
|
|
127
|
+
print_info(f"Rows: {result['rows_affected']}, Columns: {len(result['columns_affected'])}")
|
|
128
|
+
print_info(f"Column names: {', '.join(result['columns_affected'])}")
|
|
129
|
+
else:
|
|
130
|
+
print_error("Failed to load CSV")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Get session info
|
|
134
|
+
info = await get_session_info(session_id=session_id)
|
|
135
|
+
if info["success"]:
|
|
136
|
+
print_success("Retrieved session info")
|
|
137
|
+
print_data(info.get("data", info))
|
|
138
|
+
|
|
139
|
+
# List sessions
|
|
140
|
+
sessions = await list_sessions()
|
|
141
|
+
if sessions["success"]:
|
|
142
|
+
print_success(f"Listed {len(sessions.get('sessions', []))} active session(s)")
|
|
143
|
+
|
|
144
|
+
return session_id
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def test_transformations(session_id: str):
|
|
148
|
+
"""Test data transformation operations"""
|
|
149
|
+
print_test("Data Transformations")
|
|
150
|
+
|
|
151
|
+
# Filter rows
|
|
152
|
+
result = await filter_rows(
|
|
153
|
+
session_id=session_id,
|
|
154
|
+
conditions=[
|
|
155
|
+
{"column": "salary", "operator": ">", "value": 60000},
|
|
156
|
+
{"column": "department", "operator": "in", "value": ["Engineering", "Management"]},
|
|
157
|
+
],
|
|
158
|
+
mode="and",
|
|
159
|
+
)
|
|
160
|
+
if result["success"]:
|
|
161
|
+
print_success(f"Filtered rows: {result['rows_before']} → {result['rows_after']}")
|
|
162
|
+
|
|
163
|
+
# Sort data
|
|
164
|
+
result = await sort_data(
|
|
165
|
+
session_id=session_id,
|
|
166
|
+
columns=[
|
|
167
|
+
{"column": "department", "ascending": True},
|
|
168
|
+
{"column": "salary", "ascending": False},
|
|
169
|
+
],
|
|
170
|
+
)
|
|
171
|
+
if result["success"]:
|
|
172
|
+
print_success("Sorted data by department and salary")
|
|
173
|
+
|
|
174
|
+
# Select columns
|
|
175
|
+
result = await select_columns(session_id=session_id, columns=["name", "department", "salary"])
|
|
176
|
+
if result["success"]:
|
|
177
|
+
print_success(f"Selected columns: {', '.join(result['selected_columns'])}")
|
|
178
|
+
|
|
179
|
+
# Add calculated column
|
|
180
|
+
result = await add_column(
|
|
181
|
+
session_id=session_id,
|
|
182
|
+
name="salary_level",
|
|
183
|
+
value=None,
|
|
184
|
+
formula="lambda row: 'High' if row['salary'] > 65000 else 'Medium' if row['salary'] > 55000 else 'Low' if pd.notna(row['salary']) else 'Unknown'",
|
|
185
|
+
)
|
|
186
|
+
if result["success"]:
|
|
187
|
+
print_success("Added column 'salary_level'")
|
|
188
|
+
|
|
189
|
+
# Fill missing values
|
|
190
|
+
result = await fill_missing_values(session_id=session_id, strategy="mean", columns=["salary"])
|
|
191
|
+
if result["success"]:
|
|
192
|
+
print_success(f"Filled {result['values_filled']} missing value(s)")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def test_analytics(session_id: str):
|
|
196
|
+
"""Test analytics operations"""
|
|
197
|
+
print_test("Analytics")
|
|
198
|
+
|
|
199
|
+
# Get statistics
|
|
200
|
+
result = await get_statistics(session_id=session_id, columns=["salary"])
|
|
201
|
+
if result["success"]:
|
|
202
|
+
print_success("Got statistics for salary column")
|
|
203
|
+
print_data(result["statistics"])
|
|
204
|
+
|
|
205
|
+
# Get correlation matrix
|
|
206
|
+
result = await get_correlation_matrix(
|
|
207
|
+
session_id=session_id, method="pearson", min_correlation=0.3
|
|
208
|
+
)
|
|
209
|
+
if result["success"]:
|
|
210
|
+
print_success("Got correlation matrix")
|
|
211
|
+
if result["significant_correlations"]:
|
|
212
|
+
print_info("Significant correlations found:")
|
|
213
|
+
for corr in result["significant_correlations"]:
|
|
214
|
+
print(f" {corr['column1']} ↔ {corr['column2']}: {corr['correlation']:.3f}")
|
|
215
|
+
|
|
216
|
+
# Group by and aggregate
|
|
217
|
+
result = await group_by_aggregate(
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
group_by=["department"],
|
|
220
|
+
aggregations={"salary": ["mean", "min", "max", "count"]},
|
|
221
|
+
)
|
|
222
|
+
if result["success"]:
|
|
223
|
+
print_success("Grouped by department with aggregations")
|
|
224
|
+
print_info("Department salary statistics:")
|
|
225
|
+
manager = get_session_manager()
|
|
226
|
+
session = manager.get_session(session_id)
|
|
227
|
+
if session and session.df is not None:
|
|
228
|
+
print(session.df.head(10).to_string())
|
|
229
|
+
|
|
230
|
+
# Detect outliers
|
|
231
|
+
result = await detect_outliers(
|
|
232
|
+
session_id=session_id, columns=["salary"], method="iqr", threshold=1.5
|
|
233
|
+
)
|
|
234
|
+
if result["success"]:
|
|
235
|
+
print_success(f"Detected {result['total_outliers']} outlier(s)")
|
|
236
|
+
if result["outliers"]:
|
|
237
|
+
print_info("Outlier details:")
|
|
238
|
+
for col, details in result["outliers"].items():
|
|
239
|
+
print(f" {col}: {details['count']} outliers")
|
|
240
|
+
|
|
241
|
+
# Profile data
|
|
242
|
+
result = await profile_data(
|
|
243
|
+
session_id=session_id, include_correlations=True, include_outliers=True
|
|
244
|
+
)
|
|
245
|
+
if result["success"]:
|
|
246
|
+
print_success("Generated data profile")
|
|
247
|
+
print_info(
|
|
248
|
+
f"Profile summary: {result['profile']['summary']['total_rows']} rows, "
|
|
249
|
+
f"{result['profile']['summary']['total_columns']} columns"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def test_validation(session_id: str):
|
|
254
|
+
"""Test validation operations"""
|
|
255
|
+
print_test("Data Validation")
|
|
256
|
+
|
|
257
|
+
# Validate schema
|
|
258
|
+
schema = {
|
|
259
|
+
"name": {"type": "string", "required": True},
|
|
260
|
+
"salary": {"type": "numeric", "min": 0, "max": 200000},
|
|
261
|
+
"department": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"allowed_values": ["Engineering", "Management", "Marketing", "Sales"],
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
result = await validate_schema(session_id=session_id, schema=schema)
|
|
268
|
+
if result["success"]:
|
|
269
|
+
if result["valid"]:
|
|
270
|
+
print_success("Data validates against schema")
|
|
271
|
+
else:
|
|
272
|
+
print_info(f"Schema validation found {len(result['errors'])} error(s)")
|
|
273
|
+
|
|
274
|
+
# Check data quality
|
|
275
|
+
result = await check_data_quality(session_id=session_id)
|
|
276
|
+
if result["success"]:
|
|
277
|
+
quality_score = result["quality_results"]["overall_score"]
|
|
278
|
+
print_success(f"Data quality score: {quality_score:.1f}%")
|
|
279
|
+
print_info("Quality metrics:")
|
|
280
|
+
for metric, score in result["quality_results"]["metrics"].items():
|
|
281
|
+
status = "✓" if score == 100 else "⚠" if score >= 80 else "✗"
|
|
282
|
+
print(f" {status} {metric}: {score:.1f}%")
|
|
283
|
+
|
|
284
|
+
# Find anomalies
|
|
285
|
+
result = await find_anomalies(session_id=session_id, columns=["salary"])
|
|
286
|
+
if result["success"]:
|
|
287
|
+
total_anomalies = result["summary"]["total_anomalies"]
|
|
288
|
+
print_success(f"Found {total_anomalies} anomaly(ies)")
|
|
289
|
+
if total_anomalies > 0:
|
|
290
|
+
print_info("Anomaly types:")
|
|
291
|
+
for atype, count in result["summary"]["by_type"].items():
|
|
292
|
+
print(f" {atype}: {count}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def test_export(session_id: str):
|
|
296
|
+
"""Test export operations"""
|
|
297
|
+
print_test("Export Operations")
|
|
298
|
+
|
|
299
|
+
# Create test output directory
|
|
300
|
+
output_dir = Path("test_output")
|
|
301
|
+
output_dir.mkdir(exist_ok=True)
|
|
302
|
+
|
|
303
|
+
# Export to different formats
|
|
304
|
+
formats = ["csv", "json", "html", "markdown"]
|
|
305
|
+
|
|
306
|
+
for fmt in formats:
|
|
307
|
+
output_file = output_dir / f"test_export.{fmt if fmt != 'markdown' else 'md'}"
|
|
308
|
+
result = await export_csv(session_id=session_id, file_path=str(output_file), format=fmt)
|
|
309
|
+
if result["success"]:
|
|
310
|
+
print_success(f"Exported to {fmt.upper()}: {output_file}")
|
|
311
|
+
else:
|
|
312
|
+
print_error(f"Failed to export to {fmt.upper()}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def main():
|
|
316
|
+
"""Main test function"""
|
|
317
|
+
print(f"\n{Colors.HEADER}{Colors.BOLD}═══════════════════════════════════════════{Colors.ENDC}")
|
|
318
|
+
print(f"{Colors.HEADER}{Colors.BOLD} CSV MCP Server Test Suite{Colors.ENDC}")
|
|
319
|
+
print(f"{Colors.HEADER}{Colors.BOLD}═══════════════════════════════════════════{Colors.ENDC}")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
# Test I/O operations
|
|
323
|
+
session_id = await test_io_operations()
|
|
324
|
+
if not session_id:
|
|
325
|
+
print_error("Failed to create session, aborting tests")
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Test transformations
|
|
329
|
+
await test_transformations(session_id)
|
|
330
|
+
|
|
331
|
+
# Test analytics
|
|
332
|
+
await test_analytics(session_id)
|
|
333
|
+
|
|
334
|
+
# Test validation
|
|
335
|
+
await test_validation(session_id)
|
|
336
|
+
|
|
337
|
+
# Test export
|
|
338
|
+
await test_export(session_id)
|
|
339
|
+
|
|
340
|
+
print(
|
|
341
|
+
f"\n{Colors.GREEN}{Colors.BOLD}═══════════════════════════════════════════{Colors.ENDC}"
|
|
342
|
+
)
|
|
343
|
+
print(f"{Colors.GREEN}{Colors.BOLD} All tests completed successfully!{Colors.ENDC}")
|
|
344
|
+
print(
|
|
345
|
+
f"{Colors.GREEN}{Colors.BOLD}═══════════════════════════════════════════{Colors.ENDC}\n"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
print_error(f"Test failed with error: {e}")
|
|
350
|
+
import traceback
|
|
351
|
+
|
|
352
|
+
traceback.print_exc()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Smoke tests for server boot, tool registry, and CLI argument handling."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_server_imports_clean():
|
|
7
|
+
"""Importing the server module must not raise."""
|
|
8
|
+
import csv_editor.server # noqa: F401
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_tool_registry_populated():
|
|
12
|
+
"""After import, the FastMCP instance must have a populated tool registry.
|
|
13
|
+
|
|
14
|
+
Actual count as of FastMCP 3.2: 39 tools. The README claims "40+" as marketing
|
|
15
|
+
approximation; we assert >=35 to remain robust to small refactors.
|
|
16
|
+
"""
|
|
17
|
+
from csv_editor.server import mcp
|
|
18
|
+
|
|
19
|
+
tool_count = _count_registered_tools(mcp)
|
|
20
|
+
|
|
21
|
+
assert tool_count >= 35, f"Expected at least 35 tools registered, got {tool_count}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_cli_rejects_sse_transport():
|
|
25
|
+
"""The CLI must reject --transport sse with a non-zero exit."""
|
|
26
|
+
from csv_editor.server import main
|
|
27
|
+
|
|
28
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
29
|
+
main(["--transport", "sse"])
|
|
30
|
+
|
|
31
|
+
assert exc_info.value.code == 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _count_registered_tools(mcp) -> int:
|
|
35
|
+
"""Robustly count registered tools across FastMCP 2.x/3.x attribute naming."""
|
|
36
|
+
for attr in ("_tool_manager", "tool_manager", "_tools", "tools"):
|
|
37
|
+
obj = getattr(mcp, attr, None)
|
|
38
|
+
if obj is None:
|
|
39
|
+
continue
|
|
40
|
+
tools = getattr(obj, "_tools", None) or getattr(obj, "tools", None) or obj
|
|
41
|
+
try:
|
|
42
|
+
return len(tools)
|
|
43
|
+
except TypeError:
|
|
44
|
+
continue
|
|
45
|
+
list_tools = getattr(mcp, "list_tools", None)
|
|
46
|
+
if callable(list_tools):
|
|
47
|
+
import asyncio
|
|
48
|
+
result = asyncio.run(list_tools())
|
|
49
|
+
return len(result)
|
|
50
|
+
raise RuntimeError("Could not locate FastMCP tool registry")
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for CSV Editor settings functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from src.csv_editor.models.csv_session import CSVSession, CSVSettings, get_csv_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestCSVSettings:
|
|
11
|
+
"""Test CSV settings configuration."""
|
|
12
|
+
|
|
13
|
+
def test_default_settings(self):
|
|
14
|
+
"""Test default settings configuration."""
|
|
15
|
+
settings = CSVSettings()
|
|
16
|
+
assert settings.csv_history_dir == ".csv_history"
|
|
17
|
+
|
|
18
|
+
def test_settings_with_custom_dir(self):
|
|
19
|
+
"""Test settings with custom directory."""
|
|
20
|
+
with tempfile.TemporaryDirectory() as custom_dir:
|
|
21
|
+
settings = CSVSettings(csv_history_dir=custom_dir)
|
|
22
|
+
assert settings.csv_history_dir == custom_dir
|
|
23
|
+
|
|
24
|
+
def test_environment_variable_override(self):
|
|
25
|
+
"""Test that environment variable overrides default."""
|
|
26
|
+
with tempfile.TemporaryDirectory() as test_dir:
|
|
27
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": test_dir}):
|
|
28
|
+
settings = CSVSettings()
|
|
29
|
+
assert settings.csv_history_dir == test_dir
|
|
30
|
+
|
|
31
|
+
def test_case_insensitive_env_var(self):
|
|
32
|
+
"""Test that environment variable is case insensitive."""
|
|
33
|
+
with tempfile.TemporaryDirectory() as test_dir:
|
|
34
|
+
with patch.dict(os.environ, {"csv_editor_csv_history_dir": test_dir}):
|
|
35
|
+
settings = CSVSettings()
|
|
36
|
+
assert settings.csv_history_dir == test_dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestCSVSettingsIntegration:
|
|
40
|
+
"""Test CSV settings integration with sessions."""
|
|
41
|
+
|
|
42
|
+
def test_get_csv_settings_singleton(self):
|
|
43
|
+
"""Test that get_csv_settings returns singleton instance."""
|
|
44
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
45
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": temp_dir}):
|
|
46
|
+
# Reset global settings for clean test
|
|
47
|
+
with patch.object(
|
|
48
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
49
|
+
"_settings",
|
|
50
|
+
None,
|
|
51
|
+
):
|
|
52
|
+
settings1 = get_csv_settings()
|
|
53
|
+
settings2 = get_csv_settings()
|
|
54
|
+
assert settings1 is settings2
|
|
55
|
+
assert settings1.csv_history_dir == temp_dir
|
|
56
|
+
|
|
57
|
+
def test_session_uses_default_settings(self):
|
|
58
|
+
"""Test that CSVSession uses default settings."""
|
|
59
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
60
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": temp_dir}):
|
|
61
|
+
# Reset global settings to use temp directory
|
|
62
|
+
with patch.object(
|
|
63
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
64
|
+
"_settings",
|
|
65
|
+
None,
|
|
66
|
+
):
|
|
67
|
+
session = CSVSession()
|
|
68
|
+
|
|
69
|
+
assert session.history_manager is not None
|
|
70
|
+
assert session.history_manager.history_dir == temp_dir
|
|
71
|
+
|
|
72
|
+
def test_session_with_environment_variable(self):
|
|
73
|
+
"""Test that CSVSession uses environment variable settings."""
|
|
74
|
+
with tempfile.TemporaryDirectory() as test_dir:
|
|
75
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": test_dir}):
|
|
76
|
+
# Reset global settings to force reload
|
|
77
|
+
with patch.object(
|
|
78
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
79
|
+
"_settings",
|
|
80
|
+
None,
|
|
81
|
+
):
|
|
82
|
+
session = CSVSession()
|
|
83
|
+
assert session.history_manager is not None
|
|
84
|
+
assert session.history_manager.history_dir == test_dir
|
|
85
|
+
|
|
86
|
+
def test_session_history_manager_initialization(self):
|
|
87
|
+
"""Test that history manager is properly initialized with settings."""
|
|
88
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
89
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": temp_dir}):
|
|
90
|
+
# Reset global settings
|
|
91
|
+
with patch.object(
|
|
92
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
93
|
+
"_settings",
|
|
94
|
+
None,
|
|
95
|
+
):
|
|
96
|
+
session = CSVSession()
|
|
97
|
+
|
|
98
|
+
# Verify history manager configuration
|
|
99
|
+
assert session.history_manager is not None
|
|
100
|
+
assert session.history_manager.history_dir == temp_dir
|
|
101
|
+
assert session.history_manager.session_id == session.session_id
|
|
102
|
+
assert session.history_manager.enable_snapshots is True
|
|
103
|
+
assert session.history_manager.snapshot_interval == 5
|
|
104
|
+
|
|
105
|
+
def test_settings_are_configurable(self):
|
|
106
|
+
"""Test that settings can be configured multiple ways."""
|
|
107
|
+
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
|
|
108
|
+
# Test 1: Direct instantiation
|
|
109
|
+
settings1 = CSVSettings(csv_history_dir=temp_dir1)
|
|
110
|
+
assert settings1.csv_history_dir == temp_dir1
|
|
111
|
+
|
|
112
|
+
# Test 2: Environment variable
|
|
113
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": temp_dir2}):
|
|
114
|
+
settings2 = CSVSettings()
|
|
115
|
+
assert settings2.csv_history_dir == temp_dir2
|
|
116
|
+
|
|
117
|
+
# Test 3: Default
|
|
118
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
119
|
+
# Clear any existing env vars
|
|
120
|
+
if "CSV_EDITOR_CSV_HISTORY_DIR" in os.environ:
|
|
121
|
+
del os.environ["CSV_EDITOR_CSV_HISTORY_DIR"]
|
|
122
|
+
settings3 = CSVSettings()
|
|
123
|
+
assert settings3.csv_history_dir == ".csv_history"
|
|
124
|
+
|
|
125
|
+
def test_session_history_disabled(self):
|
|
126
|
+
"""Test that settings work even when history is disabled."""
|
|
127
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
128
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": temp_dir}):
|
|
129
|
+
# Reset global settings
|
|
130
|
+
with patch.object(
|
|
131
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
132
|
+
"_settings",
|
|
133
|
+
None,
|
|
134
|
+
):
|
|
135
|
+
session = CSVSession(enable_history=False)
|
|
136
|
+
|
|
137
|
+
# History manager should be None when disabled
|
|
138
|
+
assert session.history_manager is None
|
|
139
|
+
# But settings should still be accessible
|
|
140
|
+
settings = get_csv_settings()
|
|
141
|
+
assert settings.csv_history_dir == temp_dir
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestSettingsDocumentation:
|
|
145
|
+
"""Test that settings behavior matches documentation."""
|
|
146
|
+
|
|
147
|
+
def test_env_prefix_documentation(self):
|
|
148
|
+
"""Test that CSV_EDITOR_ prefix works as documented."""
|
|
149
|
+
with tempfile.TemporaryDirectory() as test_dir:
|
|
150
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": test_dir}):
|
|
151
|
+
settings = CSVSettings()
|
|
152
|
+
assert settings.csv_history_dir == test_dir
|
|
153
|
+
|
|
154
|
+
def test_default_history_subdirectory(self):
|
|
155
|
+
"""Test that default is the .csv_history subdirectory."""
|
|
156
|
+
# Clear environment and test default value without creating files
|
|
157
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
158
|
+
if "CSV_EDITOR_CSV_HISTORY_DIR" in os.environ:
|
|
159
|
+
del os.environ["CSV_EDITOR_CSV_HISTORY_DIR"]
|
|
160
|
+
|
|
161
|
+
settings = CSVSettings()
|
|
162
|
+
assert (
|
|
163
|
+
settings.csv_history_dir == ".csv_history"
|
|
164
|
+
), "Default should be .csv_history subdirectory"
|
|
165
|
+
|
|
166
|
+
def test_integration_with_history_manager(self):
|
|
167
|
+
"""Test that HistoryManager receives the configured directory."""
|
|
168
|
+
with tempfile.TemporaryDirectory() as test_dir:
|
|
169
|
+
with patch.dict(os.environ, {"CSV_EDITOR_CSV_HISTORY_DIR": test_dir}):
|
|
170
|
+
# Reset global settings
|
|
171
|
+
with patch.object(
|
|
172
|
+
__import__("src.csv_editor.models.csv_session", fromlist=["_settings"]),
|
|
173
|
+
"_settings",
|
|
174
|
+
None,
|
|
175
|
+
):
|
|
176
|
+
session = CSVSession()
|
|
177
|
+
|
|
178
|
+
# Verify the directory was passed to HistoryManager
|
|
179
|
+
assert session.history_manager.history_dir == test_dir
|
|
180
|
+
|
|
181
|
+
# Verify other HistoryManager parameters are still correctly set
|
|
182
|
+
assert session.history_manager.session_id == session.session_id
|
|
183
|
+
assert hasattr(session.history_manager, "storage_type")
|
|
184
|
+
assert hasattr(session.history_manager, "enable_snapshots")
|