@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.
Files changed (106) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +53 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/workflows/deploy-docs.yml +62 -0
  4. package/.github/workflows/publish-github.yml +52 -0
  5. package/.github/workflows/publish.yml +44 -0
  6. package/.github/workflows/test.yml +32 -0
  7. package/.pre-commit-config.yaml +157 -0
  8. package/ALTERNATIVE_PUBLISHING.md +175 -0
  9. package/ARCHITECTURE.md +1011 -0
  10. package/CHANGELOG.md +99 -0
  11. package/CODE_OF_CONDUCT.md +41 -0
  12. package/CONTRIBUTING.md +427 -0
  13. package/Dockerfile +22 -0
  14. package/LICENSE +21 -0
  15. package/MCP_CONFIG.md +505 -0
  16. package/PUBLISHING.md +210 -0
  17. package/README.md +400 -0
  18. package/SECURITY.md +61 -0
  19. package/docs/README.md +41 -0
  20. package/docs/blog/2019-05-28-first-blog-post.md +12 -0
  21. package/docs/blog/2019-05-29-long-blog-post.md +44 -0
  22. package/docs/blog/2021-08-01-mdx-blog-post.mdx +24 -0
  23. package/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg +0 -0
  24. package/docs/blog/2021-08-26-welcome/index.md +29 -0
  25. package/docs/blog/authors.yml +25 -0
  26. package/docs/blog/tags.yml +19 -0
  27. package/docs/docs/api/overview.md +183 -0
  28. package/docs/docs/installation.md +252 -0
  29. package/docs/docs/intro.md +87 -0
  30. package/docs/docs/tutorial-basics/_category_.json +8 -0
  31. package/docs/docs/tutorial-basics/congratulations.md +23 -0
  32. package/docs/docs/tutorial-basics/create-a-blog-post.md +34 -0
  33. package/docs/docs/tutorial-basics/create-a-document.md +57 -0
  34. package/docs/docs/tutorial-basics/create-a-page.md +43 -0
  35. package/docs/docs/tutorial-basics/deploy-your-site.md +31 -0
  36. package/docs/docs/tutorial-basics/markdown-features.mdx +152 -0
  37. package/docs/docs/tutorial-extras/_category_.json +7 -0
  38. package/docs/docs/tutorial-extras/img/docsVersionDropdown.png +0 -0
  39. package/docs/docs/tutorial-extras/img/localeDropdown.png +0 -0
  40. package/docs/docs/tutorial-extras/manage-docs-versions.md +55 -0
  41. package/docs/docs/tutorial-extras/translate-your-site.md +88 -0
  42. package/docs/docs/tutorials/quickstart.md +365 -0
  43. package/docs/docusaurus.config.ts +163 -0
  44. package/docs/package-lock.json +17493 -0
  45. package/docs/package.json +48 -0
  46. package/docs/sidebars.ts +33 -0
  47. package/docs/src/components/HomepageFeatures/index.tsx +71 -0
  48. package/docs/src/components/HomepageFeatures/styles.module.css +11 -0
  49. package/docs/src/css/custom.css +30 -0
  50. package/docs/src/pages/index.module.css +23 -0
  51. package/docs/src/pages/index.tsx +44 -0
  52. package/docs/src/pages/markdown-page.md +7 -0
  53. package/docs/static/.nojekyll +0 -0
  54. package/docs/static/img/docusaurus-social-card.jpg +0 -0
  55. package/docs/static/img/docusaurus.png +0 -0
  56. package/docs/static/img/favicon.ico +0 -0
  57. package/docs/static/img/logo.svg +1 -0
  58. package/docs/static/img/undraw_docusaurus_mountain.svg +171 -0
  59. package/docs/static/img/undraw_docusaurus_react.svg +170 -0
  60. package/docs/static/img/undraw_docusaurus_tree.svg +40 -0
  61. package/docs/tsconfig.json +8 -0
  62. package/examples/README.md +48 -0
  63. package/examples/auto_save_demo.py +206 -0
  64. package/examples/auto_save_overwrite.py +201 -0
  65. package/examples/basic_usage.py +135 -0
  66. package/examples/demo.py +139 -0
  67. package/examples/history_demo.py +317 -0
  68. package/examples/test_default_autosave.py +124 -0
  69. package/examples/update_consignee_example.py +179 -0
  70. package/package.json +51 -0
  71. package/plans/2026-04-19-fastmcp3-migration-plan.md +1045 -0
  72. package/pyproject.toml +331 -0
  73. package/requirements-dev.txt +30 -0
  74. package/requirements.txt +22 -0
  75. package/scripts/publish.py +67 -0
  76. package/smithery.yaml +15 -0
  77. package/specs/2026-04-19-fastmcp3-migration-design.md +243 -0
  78. package/src/csv_editor/__init__.py +8 -0
  79. package/src/csv_editor/models/__init__.py +39 -0
  80. package/src/csv_editor/models/auto_save.py +246 -0
  81. package/src/csv_editor/models/csv_session.py +468 -0
  82. package/src/csv_editor/models/data_models.py +244 -0
  83. package/src/csv_editor/models/history_manager.py +456 -0
  84. package/src/csv_editor/prompts/__init__.py +0 -0
  85. package/src/csv_editor/prompts/data_prompts.py +13 -0
  86. package/src/csv_editor/resources/__init__.py +0 -0
  87. package/src/csv_editor/resources/csv_resources.py +22 -0
  88. package/src/csv_editor/server.py +640 -0
  89. package/src/csv_editor/tools/__init__.py +5 -0
  90. package/src/csv_editor/tools/analytics.py +700 -0
  91. package/src/csv_editor/tools/auto_save_operations.py +235 -0
  92. package/src/csv_editor/tools/data_operations.py +3 -0
  93. package/src/csv_editor/tools/history_operations.py +315 -0
  94. package/src/csv_editor/tools/io_operations.py +431 -0
  95. package/src/csv_editor/tools/transformations.py +663 -0
  96. package/src/csv_editor/tools/validation.py +822 -0
  97. package/src/csv_editor/utils/__init__.py +0 -0
  98. package/src/csv_editor/utils/validators.py +205 -0
  99. package/tests/README.md +65 -0
  100. package/tests/__init__.py +7 -0
  101. package/tests/conftest.py +50 -0
  102. package/tests/test_auto_save.py +378 -0
  103. package/tests/test_basic.py +103 -0
  104. package/tests/test_integration.py +356 -0
  105. package/tests/test_server_boot.py +50 -0
  106. 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
@@ -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,7 @@
1
+ """Test suite for CSV Editor."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # Add parent directory to path for imports
7
+ sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -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"])