@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
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Validation utilities for CSV Editor."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_file_path(file_path: str, must_exist: bool = True) -> tuple[bool, str]:
|
|
13
|
+
"""Validate a file path for security and existence."""
|
|
14
|
+
try:
|
|
15
|
+
# Convert to Path object
|
|
16
|
+
path = Path(file_path).resolve()
|
|
17
|
+
|
|
18
|
+
# Security: Check for path traversal attempts
|
|
19
|
+
if ".." in file_path or file_path.startswith("~"):
|
|
20
|
+
return False, "Path traversal not allowed"
|
|
21
|
+
|
|
22
|
+
# Check file existence if required
|
|
23
|
+
if must_exist and not path.exists():
|
|
24
|
+
return False, f"File not found: {file_path}"
|
|
25
|
+
|
|
26
|
+
# Check if it's a file (not directory)
|
|
27
|
+
if must_exist and not path.is_file():
|
|
28
|
+
return False, f"Not a file: {file_path}"
|
|
29
|
+
|
|
30
|
+
# Check file extension
|
|
31
|
+
valid_extensions = [".csv", ".tsv", ".txt", ".dat"]
|
|
32
|
+
if path.suffix.lower() not in valid_extensions:
|
|
33
|
+
return False, f"Invalid file extension. Supported: {valid_extensions}"
|
|
34
|
+
|
|
35
|
+
# Check file size (max 1GB)
|
|
36
|
+
if must_exist:
|
|
37
|
+
max_size = 1024 * 1024 * 1024 # 1GB
|
|
38
|
+
if path.stat().st_size > max_size:
|
|
39
|
+
return False, "File too large. Maximum size: 1GB"
|
|
40
|
+
|
|
41
|
+
return True, str(path)
|
|
42
|
+
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return False, f"Error validating path: {e!s}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_url(url: str) -> tuple[bool, str]:
|
|
48
|
+
"""Validate a URL for CSV download."""
|
|
49
|
+
try:
|
|
50
|
+
parsed = urlparse(url)
|
|
51
|
+
|
|
52
|
+
# Check scheme
|
|
53
|
+
if parsed.scheme not in ["http", "https"]:
|
|
54
|
+
return False, "Only HTTP/HTTPS URLs are supported"
|
|
55
|
+
|
|
56
|
+
# Check if URL is valid
|
|
57
|
+
if not parsed.netloc:
|
|
58
|
+
return False, "Invalid URL format"
|
|
59
|
+
|
|
60
|
+
return True, url
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return False, f"Invalid URL: {e!s}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_column_name(column_name: str) -> tuple[bool, str]:
|
|
67
|
+
"""Validate a column name."""
|
|
68
|
+
if not column_name or not isinstance(column_name, str):
|
|
69
|
+
return False, "Column name must be a non-empty string"
|
|
70
|
+
|
|
71
|
+
# Check for invalid characters
|
|
72
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", column_name):
|
|
73
|
+
return True, column_name
|
|
74
|
+
else:
|
|
75
|
+
return (
|
|
76
|
+
False,
|
|
77
|
+
"Column name must start with letter/underscore and contain only letters, numbers, underscores",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_dataframe(df: pd.DataFrame) -> dict[str, Any]:
|
|
82
|
+
"""Validate a DataFrame for common issues."""
|
|
83
|
+
issues = {"errors": [], "warnings": [], "info": {}}
|
|
84
|
+
|
|
85
|
+
# Check if empty
|
|
86
|
+
if df.empty:
|
|
87
|
+
issues["errors"].append("DataFrame is empty")
|
|
88
|
+
return issues
|
|
89
|
+
|
|
90
|
+
# Check shape
|
|
91
|
+
issues["info"]["shape"] = df.shape
|
|
92
|
+
issues["info"]["memory_usage_mb"] = df.memory_usage(deep=True).sum() / (1024 * 1024)
|
|
93
|
+
|
|
94
|
+
# Check for duplicate columns
|
|
95
|
+
if df.columns.duplicated().any():
|
|
96
|
+
dupes = df.columns[df.columns.duplicated()].tolist()
|
|
97
|
+
issues["errors"].append(f"Duplicate column names: {dupes}")
|
|
98
|
+
|
|
99
|
+
# Check for completely null columns
|
|
100
|
+
null_cols = df.columns[df.isnull().all()].tolist()
|
|
101
|
+
if null_cols:
|
|
102
|
+
issues["warnings"].append(f"Completely null columns: {null_cols}")
|
|
103
|
+
|
|
104
|
+
# Check for mixed types in columns
|
|
105
|
+
for col in df.columns:
|
|
106
|
+
if df[col].dtype == "object":
|
|
107
|
+
# Try to infer if it's mixed types
|
|
108
|
+
unique_types = df[col].dropna().apply(type).unique()
|
|
109
|
+
if len(unique_types) > 1:
|
|
110
|
+
issues["warnings"].append(
|
|
111
|
+
f"Column '{col}' has mixed types: {[t.__name__ for t in unique_types]}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Check for high cardinality in string columns
|
|
115
|
+
for col in df.select_dtypes(include=["object"]).columns:
|
|
116
|
+
unique_ratio = df[col].nunique() / len(df)
|
|
117
|
+
if unique_ratio > 0.9:
|
|
118
|
+
issues["info"][f"{col}_high_cardinality"] = True
|
|
119
|
+
|
|
120
|
+
# Check for potential datetime columns
|
|
121
|
+
for col in df.select_dtypes(include=["object"]).columns:
|
|
122
|
+
sample = df[col].dropna().head(100)
|
|
123
|
+
if sample.empty:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
pd.to_datetime(sample, errors="raise")
|
|
127
|
+
issues["info"][f"{col}_potential_datetime"] = True
|
|
128
|
+
except:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return issues
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_expression(expression: str, allowed_vars: list[str]) -> tuple[bool, str]:
|
|
135
|
+
"""Validate a calculation expression for safety."""
|
|
136
|
+
# Remove whitespace
|
|
137
|
+
expr = expression.replace(" ", "")
|
|
138
|
+
|
|
139
|
+
# Check for dangerous operations
|
|
140
|
+
dangerous_patterns = [
|
|
141
|
+
"__",
|
|
142
|
+
"import",
|
|
143
|
+
"exec",
|
|
144
|
+
"eval",
|
|
145
|
+
"compile",
|
|
146
|
+
"open",
|
|
147
|
+
"file",
|
|
148
|
+
"input",
|
|
149
|
+
"raw_input",
|
|
150
|
+
"globals",
|
|
151
|
+
"locals",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
for pattern in dangerous_patterns:
|
|
155
|
+
if pattern in expr.lower():
|
|
156
|
+
return False, f"Dangerous operation '{pattern}' not allowed"
|
|
157
|
+
|
|
158
|
+
# Check if only allowed variables and safe operations are used
|
|
159
|
+
# This is a simplified check - in production use ast module for proper parsing
|
|
160
|
+
safe_chars = set("0123456789+-*/().,<>=! ")
|
|
161
|
+
safe_functions = {"abs", "min", "max", "sum", "len", "round", "int", "float", "str"}
|
|
162
|
+
|
|
163
|
+
# Extract potential variable/function names
|
|
164
|
+
tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", expr)
|
|
165
|
+
|
|
166
|
+
for token in tokens:
|
|
167
|
+
if token not in allowed_vars and token not in safe_functions:
|
|
168
|
+
return False, f"Unknown variable or function: {token}"
|
|
169
|
+
|
|
170
|
+
return True, expression
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_sql_query(query: str) -> tuple[bool, str]:
|
|
174
|
+
"""Validate SQL query for safety (basic check)."""
|
|
175
|
+
query_lower = query.lower()
|
|
176
|
+
|
|
177
|
+
# Only allow SELECT queries
|
|
178
|
+
if not query_lower.strip().startswith("select"):
|
|
179
|
+
return False, "Only SELECT queries are allowed"
|
|
180
|
+
|
|
181
|
+
# Check for dangerous keywords
|
|
182
|
+
dangerous = ["drop", "delete", "insert", "update", "alter", "create", "exec", "execute"]
|
|
183
|
+
for keyword in dangerous:
|
|
184
|
+
if keyword in query_lower:
|
|
185
|
+
return False, f"Dangerous operation '{keyword}' not allowed"
|
|
186
|
+
|
|
187
|
+
return True, query
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def sanitize_filename(filename: str) -> str:
|
|
191
|
+
"""Sanitize a filename for safe file operations."""
|
|
192
|
+
# Remove path components
|
|
193
|
+
filename = os.path.basename(filename)
|
|
194
|
+
|
|
195
|
+
# Remove/replace invalid characters
|
|
196
|
+
invalid_chars = '<>:"|?*'
|
|
197
|
+
for char in invalid_chars:
|
|
198
|
+
filename = filename.replace(char, "_")
|
|
199
|
+
|
|
200
|
+
# Limit length
|
|
201
|
+
name, ext = os.path.splitext(filename)
|
|
202
|
+
if len(name) > 100:
|
|
203
|
+
name = name[:100]
|
|
204
|
+
|
|
205
|
+
return name + ext
|
package/tests/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CSV Editor Tests
|
|
2
|
+
|
|
3
|
+
Comprehensive test suite for the CSV Editor MCP Server.
|
|
4
|
+
|
|
5
|
+
## Running Tests
|
|
6
|
+
|
|
7
|
+
### Run all tests
|
|
8
|
+
```bash
|
|
9
|
+
uv run pytest
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Run with coverage
|
|
13
|
+
```bash
|
|
14
|
+
uv run pytest --cov=src/csv_editor --cov-report=html
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Run specific test file
|
|
18
|
+
```bash
|
|
19
|
+
uv run pytest tests/test_basic.py
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Run integration tests
|
|
23
|
+
```bash
|
|
24
|
+
uv run pytest tests/test_integration.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Test Structure
|
|
28
|
+
|
|
29
|
+
- **test_basic.py** - Unit tests for core functionality
|
|
30
|
+
- Validators
|
|
31
|
+
- Session management
|
|
32
|
+
- Basic operations
|
|
33
|
+
|
|
34
|
+
- **test_integration.py** - Full integration tests
|
|
35
|
+
- Complete workflows
|
|
36
|
+
- All tool operations
|
|
37
|
+
- Export functionality
|
|
38
|
+
|
|
39
|
+
- **conftest.py** - Pytest configuration and fixtures
|
|
40
|
+
- Session fixtures
|
|
41
|
+
- Sample data fixtures
|
|
42
|
+
- Event loop configuration
|
|
43
|
+
|
|
44
|
+
## Writing New Tests
|
|
45
|
+
|
|
46
|
+
All test files should:
|
|
47
|
+
1. Start with `test_`
|
|
48
|
+
2. Use pytest fixtures from conftest.py
|
|
49
|
+
3. Mark async tests with `@pytest.mark.asyncio`
|
|
50
|
+
4. Clean up sessions after tests
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
```python
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_my_feature(test_session):
|
|
56
|
+
"""Test description."""
|
|
57
|
+
from src.csv_editor.tools.analytics import get_statistics
|
|
58
|
+
|
|
59
|
+
result = await get_statistics(
|
|
60
|
+
session_id=test_session,
|
|
61
|
+
columns=["price"]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert result["success"] == True
|
|
65
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pytest configuration for CSV Editor tests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
# Add src to path
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="session")
|
|
14
|
+
def event_loop():
|
|
15
|
+
"""Create an event loop for the test session."""
|
|
16
|
+
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
17
|
+
yield loop
|
|
18
|
+
loop.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def sample_csv_data():
|
|
23
|
+
"""Provide sample CSV data for testing."""
|
|
24
|
+
return """name,age,salary,department
|
|
25
|
+
Alice,30,60000,Engineering
|
|
26
|
+
Bob,25,50000,Marketing
|
|
27
|
+
Charlie,35,70000,Engineering
|
|
28
|
+
Diana,28,55000,Sales"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
async def test_session():
|
|
33
|
+
"""Create a test session."""
|
|
34
|
+
from src.csv_editor.models import get_session_manager
|
|
35
|
+
from src.csv_editor.tools.io_operations import load_csv_from_content
|
|
36
|
+
|
|
37
|
+
# Create session with sample data
|
|
38
|
+
result = await load_csv_from_content(
|
|
39
|
+
content="""product,price,quantity
|
|
40
|
+
Laptop,999.99,10
|
|
41
|
+
Mouse,29.99,50
|
|
42
|
+
Keyboard,79.99,25""",
|
|
43
|
+
delimiter=",",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
yield result["session_id"]
|
|
47
|
+
|
|
48
|
+
# Cleanup
|
|
49
|
+
manager = get_session_manager()
|
|
50
|
+
manager.remove_session(result["session_id"])
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Tests for auto-save functionality."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from src.csv_editor.models.auto_save import AutoSaveConfig, AutoSaveMode, AutoSaveStrategy
|
|
12
|
+
from src.csv_editor.models.csv_session import CSVSession, SessionManager
|
|
13
|
+
from src.csv_editor.models.data_models import ExportFormat
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def sample_df():
|
|
18
|
+
"""Create a sample DataFrame for testing."""
|
|
19
|
+
return pd.DataFrame(
|
|
20
|
+
{
|
|
21
|
+
"name": ["Alice", "Bob", "Charlie"],
|
|
22
|
+
"age": [25, 30, 35],
|
|
23
|
+
"city": ["New York", "London", "Paris"],
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def temp_dir():
|
|
30
|
+
"""Create a temporary directory for test files."""
|
|
31
|
+
temp_dir = tempfile.mkdtemp()
|
|
32
|
+
yield temp_dir
|
|
33
|
+
# Cleanup
|
|
34
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_auto_save_config_creation():
|
|
39
|
+
"""Test creating auto-save configuration."""
|
|
40
|
+
config = AutoSaveConfig(
|
|
41
|
+
enabled=True,
|
|
42
|
+
mode=AutoSaveMode.AFTER_OPERATION,
|
|
43
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
44
|
+
interval_seconds=60,
|
|
45
|
+
max_backups=5,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert config.enabled is True
|
|
49
|
+
assert config.mode == AutoSaveMode.AFTER_OPERATION
|
|
50
|
+
assert config.strategy == AutoSaveStrategy.BACKUP
|
|
51
|
+
assert config.interval_seconds == 60
|
|
52
|
+
assert config.max_backups == 5
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_auto_save_config_from_dict():
|
|
57
|
+
"""Test creating auto-save config from dictionary."""
|
|
58
|
+
config_dict = {
|
|
59
|
+
"enabled": True,
|
|
60
|
+
"mode": "periodic",
|
|
61
|
+
"strategy": "versioned",
|
|
62
|
+
"interval_seconds": 120,
|
|
63
|
+
"max_backups": 10,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
config = AutoSaveConfig.from_dict(config_dict)
|
|
67
|
+
|
|
68
|
+
assert config.enabled is True
|
|
69
|
+
assert config.mode == AutoSaveMode.PERIODIC
|
|
70
|
+
assert config.strategy == AutoSaveStrategy.VERSIONED
|
|
71
|
+
assert config.interval_seconds == 120
|
|
72
|
+
assert config.max_backups == 10
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_session_with_auto_save_disabled(sample_df):
|
|
77
|
+
"""Test session with auto-save disabled."""
|
|
78
|
+
config = AutoSaveConfig(enabled=False)
|
|
79
|
+
session = CSVSession(auto_save_config=config)
|
|
80
|
+
session.load_data(sample_df)
|
|
81
|
+
|
|
82
|
+
# Perform an operation
|
|
83
|
+
session.record_operation("test_op", {"test": "data"})
|
|
84
|
+
|
|
85
|
+
# Auto-save should not trigger
|
|
86
|
+
result = await session.trigger_auto_save_if_needed()
|
|
87
|
+
assert result is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_session_with_auto_save_after_operation(sample_df, temp_dir):
|
|
92
|
+
"""Test auto-save after each operation."""
|
|
93
|
+
config = AutoSaveConfig(
|
|
94
|
+
enabled=True,
|
|
95
|
+
mode=AutoSaveMode.AFTER_OPERATION,
|
|
96
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
97
|
+
backup_dir=temp_dir,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
session = CSVSession(auto_save_config=config)
|
|
101
|
+
session.load_data(sample_df)
|
|
102
|
+
|
|
103
|
+
# Perform an operation
|
|
104
|
+
session.record_operation("test_op", {"test": "data"})
|
|
105
|
+
|
|
106
|
+
# Trigger auto-save
|
|
107
|
+
result = await session.trigger_auto_save_if_needed()
|
|
108
|
+
|
|
109
|
+
assert result is not None
|
|
110
|
+
assert result["success"] is True
|
|
111
|
+
assert result["trigger"] == "after_operation"
|
|
112
|
+
|
|
113
|
+
# Check that backup file was created
|
|
114
|
+
backup_files = list(Path(temp_dir).glob(f"*{session.session_id}*"))
|
|
115
|
+
assert len(backup_files) == 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_manual_save(sample_df, temp_dir):
|
|
120
|
+
"""Test manual save trigger."""
|
|
121
|
+
config = AutoSaveConfig(
|
|
122
|
+
enabled=True,
|
|
123
|
+
mode=AutoSaveMode.DISABLED,
|
|
124
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
125
|
+
backup_dir=temp_dir,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
session = CSVSession(auto_save_config=config)
|
|
129
|
+
session.load_data(sample_df)
|
|
130
|
+
|
|
131
|
+
# Trigger manual save
|
|
132
|
+
result = await session.manual_save()
|
|
133
|
+
|
|
134
|
+
assert result["success"] is True
|
|
135
|
+
assert result["trigger"] == "manual"
|
|
136
|
+
|
|
137
|
+
# Check that backup file was created
|
|
138
|
+
backup_files = list(Path(temp_dir).glob(f"*{session.session_id}*"))
|
|
139
|
+
assert len(backup_files) == 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_versioned_save_strategy(sample_df, temp_dir):
|
|
144
|
+
"""Test versioned save strategy."""
|
|
145
|
+
config = AutoSaveConfig(
|
|
146
|
+
enabled=True,
|
|
147
|
+
mode=AutoSaveMode.AFTER_OPERATION,
|
|
148
|
+
strategy=AutoSaveStrategy.VERSIONED,
|
|
149
|
+
backup_dir=temp_dir,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
session = CSVSession(auto_save_config=config)
|
|
153
|
+
session.load_data(sample_df)
|
|
154
|
+
|
|
155
|
+
# Trigger multiple saves
|
|
156
|
+
for i in range(3):
|
|
157
|
+
session.record_operation(f"test_op_{i}", {"index": i})
|
|
158
|
+
await session.trigger_auto_save_if_needed()
|
|
159
|
+
|
|
160
|
+
# Check that versioned files were created
|
|
161
|
+
backup_files = sorted(Path(temp_dir).glob(f"version_{session.session_id}_v*.csv"))
|
|
162
|
+
assert len(backup_files) == 3
|
|
163
|
+
assert "v0001" in str(backup_files[0])
|
|
164
|
+
assert "v0002" in str(backup_files[1])
|
|
165
|
+
assert "v0003" in str(backup_files[2])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_max_backups_cleanup(sample_df, temp_dir):
|
|
170
|
+
"""Test that old backups are cleaned up when max_backups is exceeded."""
|
|
171
|
+
config = AutoSaveConfig(
|
|
172
|
+
enabled=True,
|
|
173
|
+
mode=AutoSaveMode.AFTER_OPERATION,
|
|
174
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
175
|
+
backup_dir=temp_dir,
|
|
176
|
+
max_backups=3,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
session = CSVSession(auto_save_config=config)
|
|
180
|
+
session.load_data(sample_df)
|
|
181
|
+
|
|
182
|
+
# Trigger more saves than max_backups
|
|
183
|
+
for i in range(5):
|
|
184
|
+
session.record_operation(f"test_op_{i}", {"index": i})
|
|
185
|
+
await session.trigger_auto_save_if_needed()
|
|
186
|
+
await asyncio.sleep(0.1) # Small delay to ensure different timestamps
|
|
187
|
+
|
|
188
|
+
# Check that only max_backups files remain
|
|
189
|
+
backup_files = list(Path(temp_dir).glob(f"*{session.session_id}*"))
|
|
190
|
+
assert len(backup_files) <= config.max_backups
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_periodic_save(sample_df, temp_dir):
|
|
195
|
+
"""Test periodic auto-save."""
|
|
196
|
+
config = AutoSaveConfig(
|
|
197
|
+
enabled=True,
|
|
198
|
+
mode=AutoSaveMode.PERIODIC,
|
|
199
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
200
|
+
backup_dir=temp_dir,
|
|
201
|
+
interval_seconds=1, # 1 second for testing
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
session = CSVSession(auto_save_config=config)
|
|
205
|
+
session.load_data(sample_df)
|
|
206
|
+
|
|
207
|
+
# Start periodic save
|
|
208
|
+
await session.auto_save_manager.start_periodic_save(session._save_callback)
|
|
209
|
+
|
|
210
|
+
# Wait for periodic save to trigger
|
|
211
|
+
await asyncio.sleep(1.5)
|
|
212
|
+
|
|
213
|
+
# Stop periodic save
|
|
214
|
+
await session.auto_save_manager.stop_periodic_save()
|
|
215
|
+
|
|
216
|
+
# Check that backup file was created
|
|
217
|
+
backup_files = list(Path(temp_dir).glob(f"*{session.session_id}*"))
|
|
218
|
+
assert len(backup_files) >= 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_hybrid_mode(sample_df, temp_dir):
|
|
223
|
+
"""Test hybrid mode (both periodic and after-operation)."""
|
|
224
|
+
config = AutoSaveConfig(
|
|
225
|
+
enabled=True,
|
|
226
|
+
mode=AutoSaveMode.HYBRID,
|
|
227
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
228
|
+
backup_dir=temp_dir,
|
|
229
|
+
interval_seconds=2,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
session = CSVSession(auto_save_config=config)
|
|
233
|
+
session.load_data(sample_df)
|
|
234
|
+
|
|
235
|
+
# Start periodic save
|
|
236
|
+
await session.auto_save_manager.start_periodic_save(session._save_callback)
|
|
237
|
+
|
|
238
|
+
# Trigger operation-based save
|
|
239
|
+
session.record_operation("test_op", {"test": "data"})
|
|
240
|
+
result = await session.trigger_auto_save_if_needed()
|
|
241
|
+
assert result["success"] is True
|
|
242
|
+
|
|
243
|
+
# Wait for periodic save
|
|
244
|
+
await asyncio.sleep(2.5)
|
|
245
|
+
|
|
246
|
+
# Stop periodic save
|
|
247
|
+
await session.auto_save_manager.stop_periodic_save()
|
|
248
|
+
|
|
249
|
+
# Should have multiple backup files
|
|
250
|
+
backup_files = list(Path(temp_dir).glob(f"*{session.session_id}*"))
|
|
251
|
+
assert len(backup_files) >= 2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_different_export_formats(sample_df, temp_dir):
|
|
256
|
+
"""Test auto-save with different export formats."""
|
|
257
|
+
formats = [ExportFormat.CSV, ExportFormat.JSON, ExportFormat.TSV]
|
|
258
|
+
|
|
259
|
+
for format in formats:
|
|
260
|
+
config = AutoSaveConfig(
|
|
261
|
+
enabled=True,
|
|
262
|
+
mode=AutoSaveMode.AFTER_OPERATION,
|
|
263
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
264
|
+
backup_dir=temp_dir,
|
|
265
|
+
format=format,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
session = CSVSession(auto_save_config=config)
|
|
269
|
+
session.load_data(sample_df)
|
|
270
|
+
|
|
271
|
+
# Trigger save
|
|
272
|
+
session.record_operation("test_op", {"format": format.value})
|
|
273
|
+
result = await session.trigger_auto_save_if_needed()
|
|
274
|
+
|
|
275
|
+
assert result["success"] is True
|
|
276
|
+
|
|
277
|
+
# Check file was created with correct extension
|
|
278
|
+
pattern = f"*{session.session_id}*.{format.value}"
|
|
279
|
+
files = list(Path(temp_dir).glob(pattern))
|
|
280
|
+
assert len(files) == 1
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_enable_disable_auto_save(sample_df, temp_dir):
|
|
285
|
+
"""Test enabling and disabling auto-save."""
|
|
286
|
+
session = CSVSession()
|
|
287
|
+
session.load_data(sample_df)
|
|
288
|
+
|
|
289
|
+
# Initially disabled
|
|
290
|
+
assert session.auto_save_config.enabled is False
|
|
291
|
+
|
|
292
|
+
# Enable auto-save
|
|
293
|
+
config_dict = {
|
|
294
|
+
"enabled": True,
|
|
295
|
+
"mode": "after_operation",
|
|
296
|
+
"strategy": "backup",
|
|
297
|
+
"backup_dir": temp_dir,
|
|
298
|
+
}
|
|
299
|
+
result = await session.enable_auto_save(config_dict)
|
|
300
|
+
assert result["success"] is True
|
|
301
|
+
assert session.auto_save_config.enabled is True
|
|
302
|
+
|
|
303
|
+
# Trigger save
|
|
304
|
+
session.record_operation("test_op", {"test": "data"})
|
|
305
|
+
save_result = await session.trigger_auto_save_if_needed()
|
|
306
|
+
assert save_result["success"] is True
|
|
307
|
+
|
|
308
|
+
# Disable auto-save
|
|
309
|
+
result = await session.disable_auto_save()
|
|
310
|
+
assert result["success"] is True
|
|
311
|
+
assert session.auto_save_config.enabled is False
|
|
312
|
+
|
|
313
|
+
# Save should not trigger
|
|
314
|
+
session.record_operation("test_op_2", {"test": "data"})
|
|
315
|
+
save_result = await session.trigger_auto_save_if_needed()
|
|
316
|
+
assert save_result is None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@pytest.mark.asyncio
|
|
320
|
+
async def test_get_auto_save_status(sample_df):
|
|
321
|
+
"""Test getting auto-save status."""
|
|
322
|
+
config = AutoSaveConfig(
|
|
323
|
+
enabled=True, mode=AutoSaveMode.AFTER_OPERATION, strategy=AutoSaveStrategy.BACKUP
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
session = CSVSession(auto_save_config=config)
|
|
327
|
+
session.load_data(sample_df)
|
|
328
|
+
|
|
329
|
+
status = session.get_auto_save_status()
|
|
330
|
+
|
|
331
|
+
assert status["enabled"] is True
|
|
332
|
+
assert status["mode"] == "after_operation"
|
|
333
|
+
assert status["strategy"] == "backup"
|
|
334
|
+
assert status["save_count"] == 0
|
|
335
|
+
assert status["periodic_active"] is False
|
|
336
|
+
|
|
337
|
+
# Trigger a save
|
|
338
|
+
await session.manual_save()
|
|
339
|
+
|
|
340
|
+
status = session.get_auto_save_status()
|
|
341
|
+
assert status["save_count"] == 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.asyncio
|
|
345
|
+
async def test_session_manager_cleanup(sample_df, temp_dir):
|
|
346
|
+
"""Test that auto-save is stopped when session is removed."""
|
|
347
|
+
config = AutoSaveConfig(
|
|
348
|
+
enabled=True,
|
|
349
|
+
mode=AutoSaveMode.PERIODIC,
|
|
350
|
+
strategy=AutoSaveStrategy.BACKUP,
|
|
351
|
+
backup_dir=temp_dir,
|
|
352
|
+
interval_seconds=1,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
manager = SessionManager()
|
|
356
|
+
session_id = manager.create_session()
|
|
357
|
+
session = manager.get_session(session_id)
|
|
358
|
+
|
|
359
|
+
# Enable auto-save
|
|
360
|
+
await session.enable_auto_save(config.to_dict())
|
|
361
|
+
session.load_data(sample_df)
|
|
362
|
+
|
|
363
|
+
# Start periodic save
|
|
364
|
+
await session.auto_save_manager.start_periodic_save(session._save_callback)
|
|
365
|
+
|
|
366
|
+
# Remove session (should stop auto-save)
|
|
367
|
+
removed = await manager.remove_session(session_id)
|
|
368
|
+
assert removed is True
|
|
369
|
+
|
|
370
|
+
# Verify periodic task was cancelled
|
|
371
|
+
assert (
|
|
372
|
+
session.auto_save_manager.periodic_task is None
|
|
373
|
+
or session.auto_save_manager.periodic_task.done()
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
pytest.main([__file__, "-v"])
|