@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
@@ -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")