@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,431 @@
1
+ """I/O operations tools for CSV Editor MCP Server."""
2
+
3
+ import tempfile
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ from fastmcp import Context
10
+
11
+ from ..models import ExportFormat, OperationResult, OperationType, get_session_manager
12
+ from ..utils.validators import validate_file_path, validate_url
13
+
14
+
15
+ async def load_csv(
16
+ file_path: str,
17
+ encoding: str = "utf-8",
18
+ delimiter: str = ",",
19
+ session_id: str | None = None,
20
+ header: int | None = 0,
21
+ na_values: list[str] | None = None,
22
+ parse_dates: list[str] | None = None,
23
+ ctx: Context = None,
24
+ ) -> dict[str, Any]:
25
+ """Load a CSV file into a session.
26
+
27
+ Args:
28
+ file_path: Path to the CSV file
29
+ encoding: File encoding (default: utf-8)
30
+ delimiter: Column delimiter (default: comma)
31
+ session_id: Optional existing session ID to use
32
+ header: Row number to use as header (default: 0)
33
+ na_values: Additional strings to recognize as NA/NaN
34
+ parse_dates: Columns to parse as dates
35
+ ctx: FastMCP context
36
+
37
+ Returns:
38
+ Operation result with session ID and data info
39
+ """
40
+ try:
41
+ # Validate file path
42
+ is_valid, validated_path = validate_file_path(file_path)
43
+ if not is_valid:
44
+ return OperationResult(
45
+ success=False, message=f"Invalid file path: {validated_path}", error=validated_path
46
+ ).model_dump()
47
+
48
+ if ctx:
49
+ await ctx.info(f"Loading CSV file: {validated_path}")
50
+ await ctx.report_progress(0.1, "Validating file...")
51
+
52
+ # Get or create session
53
+ session_manager = get_session_manager()
54
+ session = session_manager.get_or_create_session(session_id)
55
+
56
+ if ctx:
57
+ await ctx.report_progress(0.3, "Reading file...")
58
+
59
+ # Read CSV with pandas
60
+ read_params = {
61
+ "filepath_or_buffer": validated_path,
62
+ "encoding": encoding,
63
+ "delimiter": delimiter,
64
+ "header": header,
65
+ }
66
+
67
+ if na_values:
68
+ read_params["na_values"] = na_values
69
+ if parse_dates:
70
+ read_params["parse_dates"] = parse_dates
71
+
72
+ df = pd.read_csv(**read_params)
73
+
74
+ if ctx:
75
+ await ctx.report_progress(0.8, "Processing data...")
76
+
77
+ # Load into session
78
+ session.load_data(df, validated_path)
79
+
80
+ if ctx:
81
+ await ctx.report_progress(1.0, "Complete!")
82
+ await ctx.info(f"Loaded {len(df)} rows and {len(df.columns)} columns")
83
+
84
+ return OperationResult(
85
+ success=True,
86
+ message="Successfully loaded CSV file",
87
+ session_id=session.session_id,
88
+ rows_affected=len(df),
89
+ columns_affected=df.columns.tolist(),
90
+ data={
91
+ "shape": df.shape,
92
+ "dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
93
+ "memory_usage_mb": df.memory_usage(deep=True).sum() / (1024 * 1024),
94
+ "preview": df.to_dict("records"),
95
+ },
96
+ ).model_dump()
97
+
98
+ except Exception as e:
99
+ if ctx:
100
+ await ctx.error(f"Failed to load CSV: {e!s}")
101
+ return OperationResult(
102
+ success=False, message="Failed to load CSV file", error=str(e)
103
+ ).model_dump()
104
+
105
+
106
+ async def load_csv_from_url(
107
+ url: str,
108
+ encoding: str = "utf-8",
109
+ delimiter: str = ",",
110
+ session_id: str | None = None,
111
+ ctx: Context = None,
112
+ ) -> dict[str, Any]:
113
+ """Load a CSV file from a URL.
114
+
115
+ Args:
116
+ url: URL of the CSV file
117
+ encoding: File encoding
118
+ delimiter: Column delimiter
119
+ session_id: Optional existing session ID
120
+ ctx: FastMCP context
121
+
122
+ Returns:
123
+ Operation result with session ID and data info
124
+ """
125
+ try:
126
+ # Validate URL
127
+ is_valid, validated_url = validate_url(url)
128
+ if not is_valid:
129
+ return OperationResult(
130
+ success=False, message=f"Invalid URL: {validated_url}", error=validated_url
131
+ ).model_dump()
132
+
133
+ if ctx:
134
+ await ctx.info(f"Loading CSV from URL: {url}")
135
+ await ctx.report_progress(0.1, "Downloading file...")
136
+
137
+ # Download CSV using pandas (it handles URLs directly)
138
+ df = pd.read_csv(url, encoding=encoding, delimiter=delimiter)
139
+
140
+ if ctx:
141
+ await ctx.report_progress(0.8, "Processing data...")
142
+
143
+ # Get or create session
144
+ session_manager = get_session_manager()
145
+ session = session_manager.get_or_create_session(session_id)
146
+ session.load_data(df, url)
147
+
148
+ if ctx:
149
+ await ctx.report_progress(1.0, "Complete!")
150
+ await ctx.info(f"Loaded {len(df)} rows and {len(df.columns)} columns")
151
+
152
+ return OperationResult(
153
+ success=True,
154
+ message="Successfully loaded CSV from URL",
155
+ session_id=session.session_id,
156
+ rows_affected=len(df),
157
+ columns_affected=df.columns.tolist(),
158
+ data={"shape": df.shape, "source_url": url, "preview": df.head(5).to_dict("records")},
159
+ ).model_dump()
160
+
161
+ except Exception as e:
162
+ if ctx:
163
+ await ctx.error(f"Failed to load CSV from URL: {e!s}")
164
+ return OperationResult(
165
+ success=False, message="Failed to load CSV from URL", error=str(e)
166
+ ).model_dump()
167
+
168
+
169
+ async def load_csv_from_content(
170
+ content: str,
171
+ delimiter: str = ",",
172
+ session_id: str | None = None,
173
+ has_header: bool = True,
174
+ ctx: Context = None,
175
+ ) -> dict[str, Any]:
176
+ """Load CSV data from a string content.
177
+
178
+ Args:
179
+ content: CSV content as string
180
+ delimiter: Column delimiter
181
+ session_id: Optional existing session ID
182
+ has_header: Whether first row is header
183
+ ctx: FastMCP context
184
+
185
+ Returns:
186
+ Operation result with session ID and data info
187
+ """
188
+ try:
189
+ if ctx:
190
+ await ctx.info("Loading CSV from content string")
191
+
192
+ # Parse CSV from string
193
+ from io import StringIO
194
+
195
+ df = pd.read_csv(StringIO(content), delimiter=delimiter, header=0 if has_header else None)
196
+
197
+ # Get or create session
198
+ session_manager = get_session_manager()
199
+ session = session_manager.get_or_create_session(session_id)
200
+ session.load_data(df, None)
201
+
202
+ if ctx:
203
+ await ctx.info(f"Loaded {len(df)} rows and {len(df.columns)} columns")
204
+
205
+ return OperationResult(
206
+ success=True,
207
+ message="Successfully loaded CSV from content",
208
+ session_id=session.session_id,
209
+ rows_affected=len(df),
210
+ columns_affected=df.columns.tolist(),
211
+ data={"shape": df.shape, "preview": df.head(5).to_dict("records")},
212
+ ).model_dump()
213
+
214
+ except Exception as e:
215
+ if ctx:
216
+ await ctx.error(f"Failed to parse CSV content: {e!s}")
217
+ return OperationResult(
218
+ success=False, message="Failed to parse CSV content", error=str(e)
219
+ ).model_dump()
220
+
221
+
222
+ async def export_csv(
223
+ session_id: str,
224
+ file_path: str | None = None,
225
+ format: ExportFormat = ExportFormat.CSV,
226
+ encoding: str = "utf-8",
227
+ index: bool = False,
228
+ ctx: Context = None,
229
+ ) -> dict[str, Any]:
230
+ """Export session data to various formats.
231
+
232
+ Args:
233
+ session_id: Session ID to export
234
+ file_path: Optional output file path (auto-generated if not provided)
235
+ format: Export format (csv, tsv, json, excel, parquet, html, markdown)
236
+ encoding: Output encoding
237
+ index: Whether to include index in output
238
+ ctx: FastMCP context
239
+
240
+ Returns:
241
+ Operation result with file path
242
+ """
243
+ try:
244
+ # Get session
245
+ session_manager = get_session_manager()
246
+ session = session_manager.get_session(session_id)
247
+
248
+ if not session or session.df is None:
249
+ return OperationResult(
250
+ success=False,
251
+ message="Session not found or no data loaded",
252
+ error="Invalid session ID",
253
+ ).model_dump()
254
+
255
+ if ctx:
256
+ await ctx.info(f"Exporting data in {format.value} format")
257
+ await ctx.report_progress(0.1, "Preparing export...")
258
+
259
+ # Generate file path if not provided
260
+ if not file_path:
261
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
262
+ filename = f"export_{session_id[:8]}_{timestamp}"
263
+
264
+ # Determine extension based on format
265
+ extensions = {
266
+ ExportFormat.CSV: ".csv",
267
+ ExportFormat.TSV: ".tsv",
268
+ ExportFormat.JSON: ".json",
269
+ ExportFormat.EXCEL: ".xlsx",
270
+ ExportFormat.PARQUET: ".parquet",
271
+ ExportFormat.HTML: ".html",
272
+ ExportFormat.MARKDOWN: ".md",
273
+ }
274
+
275
+ file_path = tempfile.gettempdir() + "/" + filename + extensions[format]
276
+
277
+ file_path = Path(file_path)
278
+ df = session.df
279
+
280
+ if ctx:
281
+ await ctx.report_progress(0.5, f"Writing {format.value} file...")
282
+
283
+ # Export based on format
284
+ if format == ExportFormat.CSV:
285
+ df.to_csv(file_path, encoding=encoding, index=index)
286
+ elif format == ExportFormat.TSV:
287
+ df.to_csv(file_path, sep="\t", encoding=encoding, index=index)
288
+ elif format == ExportFormat.JSON:
289
+ df.to_json(file_path, orient="records", indent=2)
290
+ elif format == ExportFormat.EXCEL:
291
+ df.to_excel(file_path, index=index, engine="openpyxl")
292
+ elif format == ExportFormat.PARQUET:
293
+ df.to_parquet(file_path, index=index)
294
+ elif format == ExportFormat.HTML:
295
+ df.to_html(file_path, index=index)
296
+ elif format == ExportFormat.MARKDOWN:
297
+ df.to_markdown(file_path, index=index)
298
+ else:
299
+ return OperationResult(
300
+ success=False,
301
+ message=f"Unsupported format: {format}",
302
+ error="Invalid export format",
303
+ ).model_dump()
304
+
305
+ # Record operation
306
+ session.record_operation(
307
+ OperationType.EXPORT, {"format": format.value, "file_path": str(file_path)}
308
+ )
309
+
310
+ if ctx:
311
+ await ctx.report_progress(1.0, "Export complete!")
312
+ await ctx.info(f"Exported to {file_path}")
313
+
314
+ return OperationResult(
315
+ success=True,
316
+ message=f"Successfully exported data to {format.value}",
317
+ session_id=session_id,
318
+ data={
319
+ "file_path": str(file_path),
320
+ "format": format.value,
321
+ "rows_exported": len(df),
322
+ "file_size_bytes": file_path.stat().st_size,
323
+ },
324
+ ).model_dump()
325
+
326
+ except Exception as e:
327
+ if ctx:
328
+ await ctx.error(f"Failed to export data: {e!s}")
329
+ return OperationResult(
330
+ success=False, message="Failed to export data", error=str(e)
331
+ ).model_dump()
332
+
333
+
334
+ async def get_session_info(session_id: str, ctx: Context = None) -> dict[str, Any]:
335
+ """Get information about a specific session.
336
+
337
+ Args:
338
+ session_id: Session ID
339
+ ctx: FastMCP context
340
+
341
+ Returns:
342
+ Session information
343
+ """
344
+ try:
345
+ session_manager = get_session_manager()
346
+ session = session_manager.get_session(session_id)
347
+
348
+ if not session:
349
+ return OperationResult(
350
+ success=False, message="Session not found", error="Invalid session ID"
351
+ ).model_dump()
352
+
353
+ if ctx:
354
+ await ctx.info(f"Retrieved info for session {session_id}")
355
+
356
+ info = session.get_info()
357
+ return OperationResult(
358
+ success=True,
359
+ message="Session info retrieved",
360
+ session_id=session_id,
361
+ data=info.model_dump(),
362
+ ).model_dump()
363
+
364
+ except Exception as e:
365
+ if ctx:
366
+ await ctx.error(f"Failed to get session info: {e!s}")
367
+ return OperationResult(
368
+ success=False, message="Failed to get session info", error=str(e)
369
+ ).model_dump()
370
+
371
+
372
+ async def list_sessions(ctx: Context = None) -> dict[str, Any]:
373
+ """List all active sessions.
374
+
375
+ Args:
376
+ ctx: FastMCP context
377
+
378
+ Returns:
379
+ List of active sessions
380
+ """
381
+ try:
382
+ session_manager = get_session_manager()
383
+ sessions = session_manager.list_sessions()
384
+
385
+ if ctx:
386
+ await ctx.info(f"Found {len(sessions)} active sessions")
387
+
388
+ return {
389
+ "success": True,
390
+ "message": f"Found {len(sessions)} active sessions",
391
+ "sessions": [s.model_dump() for s in sessions],
392
+ }
393
+
394
+ except Exception as e:
395
+ if ctx:
396
+ await ctx.error(f"Failed to list sessions: {e!s}")
397
+ return {"success": False, "message": "Failed to list sessions", "error": str(e)}
398
+
399
+
400
+ async def close_session(session_id: str, ctx: Context = None) -> dict[str, Any]:
401
+ """Close and clean up a session.
402
+
403
+ Args:
404
+ session_id: Session ID to close
405
+ ctx: FastMCP context
406
+
407
+ Returns:
408
+ Operation result
409
+ """
410
+ try:
411
+ session_manager = get_session_manager()
412
+ removed = await session_manager.remove_session(session_id)
413
+
414
+ if not removed:
415
+ return OperationResult(
416
+ success=False, message="Session not found", error="Invalid session ID"
417
+ ).model_dump()
418
+
419
+ if ctx:
420
+ await ctx.info(f"Closed session {session_id}")
421
+
422
+ return OperationResult(
423
+ success=True, message=f"Session {session_id} closed successfully", session_id=session_id
424
+ ).model_dump()
425
+
426
+ except Exception as e:
427
+ if ctx:
428
+ await ctx.error(f"Failed to close session: {e!s}")
429
+ return OperationResult(
430
+ success=False, message="Failed to close session", error=str(e)
431
+ ).model_dump()