excel-mcp-server 2.2.6__tar.gz → 2.2.7__tar.gz

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 (230) hide show
  1. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/PKG-INFO +3 -3
  2. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/README.md +2 -2
  3. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/pyproject.toml +1 -1
  4. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/pyproject.toml.orig +1 -1
  5. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/engine.py +1 -1
  6. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/cells.py +38 -4
  7. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/csv_export.py +9 -2
  8. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/csv_import.py +22 -5
  9. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/duplicates.py +12 -2
  10. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/fill.py +17 -5
  11. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/layout.py +27 -14
  12. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot.py +9 -6
  13. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_records.py +11 -4
  14. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_render.py +9 -3
  15. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_source.py +15 -0
  16. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/replace.py +41 -5
  17. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicers.py +4 -1
  18. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sorting.py +19 -11
  19. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/table_options.py +3 -3
  20. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/text_split.py +65 -20
  21. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/transform.py +3 -1
  22. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/data_tools.py +2 -1
  23. excel_mcp_server-2.2.7/src/excel_mcp/text_budget.py +35 -0
  24. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/workspace.py +19 -1
  25. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/LICENSE +0 -0
  26. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/__init__.py +0 -0
  27. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/__main__.py +0 -0
  28. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/cells.py +0 -0
  29. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/criteria.py +0 -0
  30. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/dependencies.py +0 -0
  31. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/dynamic.py +0 -0
  32. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/__init__.py +0 -0
  33. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/arrays.py +0 -0
  34. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/conditional.py +0 -0
  35. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/datetime.py +0 -0
  36. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/depreciation.py +0 -0
  37. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/distributions.py +0 -0
  38. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/financial.py +0 -0
  39. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/helpers.py +0 -0
  40. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/indirect.py +0 -0
  41. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/information.py +0 -0
  42. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/logical.py +0 -0
  43. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/lookup.py +0 -0
  44. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/math.py +0 -0
  45. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/regression.py +0 -0
  46. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/securities.py +0 -0
  47. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/statistical.py +0 -0
  48. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/subtotals.py +0 -0
  49. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/functions/text.py +0 -0
  50. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/operators.py +0 -0
  51. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/parser.py +0 -0
  52. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/r1c1.py +0 -0
  53. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/registry.py +0 -0
  54. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/scheduler.py +0 -0
  55. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/solver.py +0 -0
  56. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/steps.py +0 -0
  57. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/tablerefs.py +0 -0
  58. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/textformat.py +0 -0
  59. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/values.py +0 -0
  60. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/calc/wildcard.py +0 -0
  61. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/cell_budget.py +0 -0
  62. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/cfb.py +0 -0
  63. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/chart_roundtrip.py +0 -0
  64. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/cli.py +0 -0
  65. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/config.py +0 -0
  66. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/errors.py +0 -0
  67. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/formula_syntax.py +0 -0
  68. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/formulas.py +0 -0
  69. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/function_arguments.json +0 -0
  70. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/inputs.py +0 -0
  71. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_edit.py +0 -0
  72. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_find.py +0 -0
  73. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_lines.py +0 -0
  74. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_rows.py +0 -0
  75. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_shared.py +0 -0
  76. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_source.py +0 -0
  77. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/lazy_workbook.py +0 -0
  78. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/links.py +0 -0
  79. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/macros.py +0 -0
  80. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/__init__.py +0 -0
  81. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/calculated.py +0 -0
  82. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chart_index.py +0 -0
  83. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chart_info.py +0 -0
  84. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex.py +0 -0
  85. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/box_whisker_style.xml +0 -0
  86. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/colors.xml +0 -0
  87. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/funnel_style.xml +0 -0
  88. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/histogram_style.xml +0 -0
  89. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/sunburst_style.xml +0 -0
  90. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/treemap_style.xml +0 -0
  91. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_parts/waterfall_style.xml +0 -0
  92. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/chartex_xml.py +0 -0
  93. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts.py +0 -0
  94. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_axes.py +0 -0
  95. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_check.py +0 -0
  96. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_data.py +0 -0
  97. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_look.py +0 -0
  98. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_modern.py +0 -0
  99. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_options.py +0 -0
  100. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_series.py +0 -0
  101. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/charts_style.py +0 -0
  102. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/columns.py +0 -0
  103. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/comparison.py +0 -0
  104. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/conditional.py +0 -0
  105. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/conditional_kinds.py +0 -0
  106. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/conditional_rule.py +0 -0
  107. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/conditional_x14.py +0 -0
  108. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/csv_format.py +0 -0
  109. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/csv_values.py +0 -0
  110. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/date_width.py +0 -0
  111. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/drawings.py +0 -0
  112. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/files.py +0 -0
  113. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/filter_criteria.py +0 -0
  114. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/filter_stored.py +0 -0
  115. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/filters.py +0 -0
  116. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/formatting.py +0 -0
  117. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/goal_seek.py +0 -0
  118. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/hyperlinks.py +0 -0
  119. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/images.py +0 -0
  120. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/inspect.py +0 -0
  121. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/line_edit_objects.py +0 -0
  122. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/line_edit_rules.py +0 -0
  123. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/line_edit_styles.py +0 -0
  124. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/line_edits.py +0 -0
  125. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/names.py +0 -0
  126. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/notes.py +0 -0
  127. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/paste.py +0 -0
  128. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_axis.py +0 -0
  129. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_cache.py +0 -0
  130. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_calc.py +0 -0
  131. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_definition.py +0 -0
  132. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_fields.py +0 -0
  133. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_groups.py +0 -0
  134. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_index.py +0 -0
  135. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_options.py +0 -0
  136. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_rebuild.py +0 -0
  137. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_showas.py +0 -0
  138. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_specs.py +0 -0
  139. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/pivot_values.py +0 -0
  140. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/print_setup.py +0 -0
  141. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/protection.py +0 -0
  142. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/rule_clear.py +0 -0
  143. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/rules.py +0 -0
  144. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheet_copy.py +0 -0
  145. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheet_counts.py +0 -0
  146. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheet_refs.py +0 -0
  147. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheet_shapes.py +0 -0
  148. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheet_view.py +0 -0
  149. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sheets.py +0 -0
  150. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/shifting.py +0 -0
  151. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_index.py +0 -0
  152. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_manage.py +0 -0
  153. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_pivot.py +0 -0
  154. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_place.py +0 -0
  155. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_table.py +0 -0
  156. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/slicer_xml.py +0 -0
  157. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/spans.py +0 -0
  158. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sparkline_style.py +0 -0
  159. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sparkline_xml.py +0 -0
  160. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/sparklines.py +0 -0
  161. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/spill.py +0 -0
  162. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/tables.py +0 -0
  163. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/trace.py +0 -0
  164. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/used_range.py +0 -0
  165. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/vba.py +0 -0
  166. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/vba_write.py +0 -0
  167. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/workbook_rewrite.py +0 -0
  168. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/workbook_settings.py +0 -0
  169. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/operations/x14_xml.py +0 -0
  170. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/ovba.py +0 -0
  171. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/ovba_compress.py +0 -0
  172. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/ovba_write.py +0 -0
  173. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/__init__.py +0 -0
  174. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/anchors.py +0 -0
  175. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/arrays.py +0 -0
  176. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/capture.py +0 -0
  177. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/capture_sheet.py +0 -0
  178. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/consistency.py +0 -0
  179. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/extensions.py +0 -0
  180. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/guards.py +0 -0
  181. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/lazy_records.py +0 -0
  182. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/lines.py +0 -0
  183. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/metadata.py +0 -0
  184. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/model.py +0 -0
  185. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/opc.py +0 -0
  186. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/patch.py +0 -0
  187. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/pivot_records.py +0 -0
  188. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/pivots.py +0 -0
  189. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/properties.py +0 -0
  190. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/reader.py +0 -0
  191. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/references.py +0 -0
  192. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/restore.py +0 -0
  193. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/restore_sheet.py +0 -0
  194. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/scan.py +0 -0
  195. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/shape_names.py +0 -0
  196. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/package/vml.py +0 -0
  197. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/paths.py +0 -0
  198. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/plain_refs.py +0 -0
  199. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/py.typed +0 -0
  200. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/refs.py +0 -0
  201. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/rewrite.py +0 -0
  202. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/__init__.py +0 -0
  203. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/annotation_tools.py +0 -0
  204. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/areas.py +0 -0
  205. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/csv_tools.py +0 -0
  206. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/format_tools.py +0 -0
  207. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/formula_tools.py +0 -0
  208. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/http.py +0 -0
  209. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/image_tools.py +0 -0
  210. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/object_tools.py +0 -0
  211. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/params.py +0 -0
  212. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/pivot_tools.py +0 -0
  213. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/registry.py +0 -0
  214. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/results.py +0 -0
  215. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/sheet_tools.py +0 -0
  216. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/slicer_tools.py +0 -0
  217. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/validation.py +0 -0
  218. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/vba_tools.py +0 -0
  219. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/vba_write_tools.py +0 -0
  220. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/server/workbook_tools.py +0 -0
  221. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/spill.py +0 -0
  222. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/structured.py +0 -0
  223. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/text.py +0 -0
  224. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/upload_links.py +0 -0
  225. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/upload_scan.py +0 -0
  226. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/values.py +0 -0
  227. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/xlfn.py +0 -0
  228. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/xlm.py +0 -0
  229. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/xml_text.py +0 -0
  230. {excel_mcp_server-2.2.6 → excel_mcp_server-2.2.7}/src/excel_mcp/zip_limits.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: excel-mcp-server
3
- Version: 2.2.6
3
+ Version: 2.2.7
4
4
  Summary: MCP server for creating, reading and editing Excel workbooks without Microsoft Excel
5
5
  Keywords: mcp,mcp-server,model-context-protocol,excel,xlsx,xlsm,spreadsheet,openpyxl,charts,pivot-table,formulas,vba,llm,ai,claude,cursor
6
6
  Author: Haris Musa
@@ -233,7 +233,7 @@ docker run -p 8017:8017 -v "$PWD/workbooks:/data" -e EXCEL_MCP_AUTH_TOKEN=change
233
233
  | `--allow-vba-write` | `EXCEL_MCP_ALLOW_VBA_WRITE=1` | off | Add tools that write VBA macros (see warning below); ignored with `--read-only` |
234
234
  | `--tools AREAS` | `EXCEL_MCP_TOOLS` | all | Only offer the core tools and these comma-separated areas, to save context (see [Choosing tools](#choosing-tools)) |
235
235
  | `--max-file-mb N` | `EXCEL_MCP_MAX_FILE_MB` | `100` | Largest workbook the server opens. Also sets the unpacked-size limit (10 times this) that every opened or uploaded workbook must stay under; parts compressed over 500:1 are refused |
236
- | `--max-cells N` | `EXCEL_MCP_MAX_CELLS` | `100000` | Most cells one call may read, write or format; reads are still returned in pages |
236
+ | `--max-cells N` | `EXCEL_MCP_MAX_CELLS` | `100000` | Most cells one call may read, write or format; reads are still returned in pages; also bounds text to 256 characters per cell on average |
237
237
  | `--max-pivot-cells N` | `EXCEL_MCP_MAX_PIVOT_CELLS` | `3000000` | Largest PivotTable source in cells (a 230,000-row, 13-column sheet); creating one from the biggest sources takes a minute and about 2 GB of memory |
238
238
  | `--max-loaded-cells N` | `EXCEL_MCP_MAX_LOADED_CELLS` | `3000000` | Most cells and rows of a workbook that may be loaded into memory at once, about 0.5 KB each (a 3,000,000 limit is about 1.5 GB). A workbook with more is refused by the tools that must load whole sheets; `read_range` and the other streaming reads, and edits of large sheets that parse only some rows, are not limited by the size of the sheet |
239
239
  | `--log-level LEVEL` | | `WARNING` | Logging on stderr |
@@ -343,7 +343,7 @@ refused, since any change would invalidate the signature.
343
343
  `http(s)://`, `mailto:` or `#Sheet!A1` link only.
344
344
  - Paths are resolved, including symlinks, before they are checked against the allowed
345
345
  folders. Network (UNC) and Windows device paths are always rejected.
346
- - A single call processes at most 100,000 cells (`--max-cells`), and large reads are returned in pages.
346
+ - A single call processes at most 100,000 cells (`--max-cells`) and, on average, 256 characters of text per cell (25.6 million characters at the default), and large reads are returned in pages. Text a workbook stores once but many cells hold is counted in every cell, and a workbook that saving would expand past the unpacked-size limit is refused.
347
347
  - Cell contents are data from files. The server tells the model not to follow instructions
348
348
  found in them, but review what an assistant does with workbooks from untrusted sources.
349
349
 
@@ -197,7 +197,7 @@ docker run -p 8017:8017 -v "$PWD/workbooks:/data" -e EXCEL_MCP_AUTH_TOKEN=change
197
197
  | `--allow-vba-write` | `EXCEL_MCP_ALLOW_VBA_WRITE=1` | off | Add tools that write VBA macros (see warning below); ignored with `--read-only` |
198
198
  | `--tools AREAS` | `EXCEL_MCP_TOOLS` | all | Only offer the core tools and these comma-separated areas, to save context (see [Choosing tools](#choosing-tools)) |
199
199
  | `--max-file-mb N` | `EXCEL_MCP_MAX_FILE_MB` | `100` | Largest workbook the server opens. Also sets the unpacked-size limit (10 times this) that every opened or uploaded workbook must stay under; parts compressed over 500:1 are refused |
200
- | `--max-cells N` | `EXCEL_MCP_MAX_CELLS` | `100000` | Most cells one call may read, write or format; reads are still returned in pages |
200
+ | `--max-cells N` | `EXCEL_MCP_MAX_CELLS` | `100000` | Most cells one call may read, write or format; reads are still returned in pages; also bounds text to 256 characters per cell on average |
201
201
  | `--max-pivot-cells N` | `EXCEL_MCP_MAX_PIVOT_CELLS` | `3000000` | Largest PivotTable source in cells (a 230,000-row, 13-column sheet); creating one from the biggest sources takes a minute and about 2 GB of memory |
202
202
  | `--max-loaded-cells N` | `EXCEL_MCP_MAX_LOADED_CELLS` | `3000000` | Most cells and rows of a workbook that may be loaded into memory at once, about 0.5 KB each (a 3,000,000 limit is about 1.5 GB). A workbook with more is refused by the tools that must load whole sheets; `read_range` and the other streaming reads, and edits of large sheets that parse only some rows, are not limited by the size of the sheet |
203
203
  | `--log-level LEVEL` | | `WARNING` | Logging on stderr |
@@ -307,7 +307,7 @@ refused, since any change would invalidate the signature.
307
307
  `http(s)://`, `mailto:` or `#Sheet!A1` link only.
308
308
  - Paths are resolved, including symlinks, before they are checked against the allowed
309
309
  folders. Network (UNC) and Windows device paths are always rejected.
310
- - A single call processes at most 100,000 cells (`--max-cells`), and large reads are returned in pages.
310
+ - A single call processes at most 100,000 cells (`--max-cells`) and, on average, 256 characters of text per cell (25.6 million characters at the default), and large reads are returned in pages. Text a workbook stores once but many cells hold is counted in every cell, and a workbook that saving would expand past the unpacked-size limit is refused.
311
311
  - Cell contents are data from files. The server tells the model not to follow instructions
312
312
  found in them, but review what an assistant does with workbooks from untrusted sources.
313
313
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "excel-mcp-server"
3
- version = "2.2.6"
3
+ version = "2.2.7"
4
4
  description = "MCP server for creating, reading and editing Excel workbooks without Microsoft Excel"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "excel-mcp-server"
3
- version = "2.2.6"
3
+ version = "2.2.7"
4
4
  description = "MCP server for creating, reading and editing Excel workbooks without Microsoft Excel"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -121,7 +121,7 @@ class Engine:
121
121
  if cell is None:
122
122
  return None
123
123
  if cell.data_type != "f":
124
- return constant(cell)
124
+ return self.sized(constant(cell))
125
125
  key = (sheet.title, row, col)
126
126
  if key in self.memo:
127
127
  remembered = self.memo[key]
@@ -1,12 +1,14 @@
1
1
  """Reading, writing, clearing, copying and searching cell contents."""
2
2
 
3
3
  from collections.abc import Iterator
4
+ from contextlib import closing
4
5
 
5
6
  from openpyxl.cell.cell import Cell, MergedCell
6
7
  from openpyxl.worksheet._read_only import ReadOnlyWorksheet
7
8
  from openpyxl.worksheet.worksheet import Worksheet
8
9
  from pydantic import BaseModel
9
10
 
11
+ from excel_mcp.config import Limits
10
12
  from excel_mcp.errors import InvalidArgumentError, LimitExceededError
11
13
  from excel_mcp.lazy_workbook import LazyCells, held_cells
12
14
  from excel_mcp.operations.date_width import FITTED_WIDTHS, widen_for_dates
@@ -22,6 +24,7 @@ from excel_mcp.refs import (
22
24
  parse_clamped_range,
23
25
  parse_range,
24
26
  )
27
+ from excel_mcp.text_budget import CHARS_PER_CELL, TextBudget
25
28
  from excel_mcp.values import CellValue, date_number_format, to_cell, to_json, typed_value
26
29
  from excel_mcp.workspace import sheet_names
27
30
  from excel_mcp.xlfn import display_formula
@@ -192,7 +195,24 @@ def read_range(sheet: ReadOnlyWorksheet, ref: str | None, max_cells: int) -> Ran
192
195
  max_col=target.max_col,
193
196
  values_only=True,
194
197
  )
195
- values = [[_displayed(to_json(value)) for value in row] for row in rows]
198
+ values = []
199
+ characters, limit, cut = 0, max_cells * CHARS_PER_CELL, False
200
+ with closing(rows):
201
+ for row in rows:
202
+ line = [_displayed(to_json(value)) for value in row]
203
+ size = sum(len(value) for value in line if isinstance(value, str))
204
+ if characters + size > limit:
205
+ if not values:
206
+ raise LimitExceededError(
207
+ f"Row {target.min_row} of {target} holds more than {limit:,} characters of "
208
+ "text, the limit per page. Read fewer columns."
209
+ )
210
+ cut = True
211
+ break
212
+ characters += size
213
+ values.append(line)
214
+ if cut:
215
+ last_row = target.min_row + len(values) - 1
196
216
  for row in values:
197
217
  while row and row[-1] is None:
198
218
  row.pop()
@@ -279,21 +299,35 @@ def find_cells(
279
299
  exact: bool,
280
300
  case_sensitive: bool,
281
301
  max_results: int,
302
+ limits: Limits,
282
303
  ) -> FindResult:
304
+ """Cells whose text contains (or equals) ``query``. The text searched and the text returned
305
+ are counted, so a workbook of many cells sharing one long text cannot make a search long or
306
+ its result large."""
307
+
283
308
  def normalize(text: str) -> str:
284
309
  return text if case_sensitive else text.casefold()
285
310
 
311
+ scanned = TextBudget(
312
+ limits.max_loaded_cells,
313
+ "Searching would read more than {limit} characters of text. Search one sheet at a "
314
+ "time, or raise --max-loaded-cells.",
315
+ )
316
+ page = limits.max_read_cells * CHARS_PER_CELL
286
317
  needle = normalize(query)
287
318
  matches: dict[str, dict[str, CellValue]] = {}
288
- count = 0
319
+ count = returned = 0
289
320
  for sheet in sheets:
290
321
  for row, col, value in streamed_cells(sheet):
291
322
  shown = _displayed(to_json(value))
292
- text = normalize(str(shown))
323
+ text = str(shown)
324
+ scanned.spend(len(text))
325
+ text = normalize(text)
293
326
  if not (text == needle if exact else needle in text):
294
327
  continue
295
- if count == max_results:
328
+ if count == max_results or (count and returned + len(text) > page):
296
329
  return FindResult(matches=matches, truncated=True)
297
330
  matches.setdefault(sheet.title, {})[cell_name(row, col)] = shown
298
331
  count += 1
332
+ returned += len(text)
299
333
  return FindResult(matches=matches)
@@ -14,6 +14,7 @@ from excel_mcp.errors import InvalidArgumentError, LimitExceededError
14
14
  from excel_mcp.operations.calculated import read_calculated
15
15
  from excel_mcp.operations.csv_format import DELIMITER_CHARACTERS, FORMULA_STARTS, Encoding
16
16
  from excel_mcp.refs import CellRange, cell_name, parse_range
17
+ from excel_mcp.text_budget import CHARS_PER_CELL, TextBudget
17
18
  from excel_mcp.values import CellValue
18
19
  from excel_mcp.workspace import Workspace, get_streamed_sheet
19
20
 
@@ -42,9 +43,14 @@ def export_csv(
42
43
  data = read_calculated(workspace, path, sheet, ref, max_cells)
43
44
  if data.next_range:
44
45
  raise LimitExceededError(
45
- f"The range has more than {max_cells:,} cells, the limit per call. Export a "
46
- "smaller range or raise --max-cells."
46
+ f"The range has more than {max_cells:,} cells or {max_cells * CHARS_PER_CELL:,} "
47
+ "characters of text, the limit per call. Export a smaller range or raise --max-cells."
47
48
  )
49
+ budget = TextBudget(
50
+ max_cells,
51
+ "The range as displayed has more than {limit} characters of text, the limit per call. "
52
+ "Export a smaller range or raise --max-cells.",
53
+ )
48
54
  area = parse_range(data.range)
49
55
  formats = _formats(workspace, path, sheet, area) if formatted else None
50
56
  buffer = io.StringIO(newline="")
@@ -57,6 +63,7 @@ def export_csv(
57
63
  number_format = formats[row][column] if formats else None
58
64
  try:
59
65
  fields.append(_field(value, number_format, escape_formulas))
66
+ budget.spend(len(fields[-1]))
60
67
  except UncalculableError as reason:
61
68
  where = cell_name(area.min_row + row, area.min_col + column)
62
69
  raise InvalidArgumentError(
@@ -1,8 +1,10 @@
1
1
  """Reading a CSV file into a worksheet."""
2
2
 
3
3
  import csv
4
+ from collections.abc import Iterator
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
7
+ from typing import TextIO
6
8
 
7
9
  from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
8
10
  from openpyxl.worksheet.worksheet import Worksheet
@@ -48,7 +50,8 @@ def import_csv(
48
50
  with file.open(encoding=codec, newline="") as stream:
49
51
  separator = _separator(stream.read(_SAMPLE_CHARACTERS), delimiter)
50
52
  stream.seek(0)
51
- for offset, record in enumerate(csv.reader(stream, delimiter=separator), start=1):
53
+ lines = _lines_within(stream, separator, max_cells)
54
+ for offset, record in enumerate(csv.reader(lines, delimiter=separator), start=1):
52
55
  row = first_row + offset - 1
53
56
  if record and (row > MAX_ROW or first_col + len(record) - 1 > MAX_COLUMN):
54
57
  raise InvalidArgumentError(
@@ -56,10 +59,7 @@ def import_csv(
56
59
  )
57
60
  cells += len(record)
58
61
  if cells > max_cells:
59
- raise LimitExceededError(
60
- f"The file has more than {max_cells:,} cells, the limit per call. "
61
- "Split the file or raise --max-cells."
62
- )
62
+ raise _too_many_cells(max_cells)
63
63
  for index, text in enumerate(record):
64
64
  column = first_col + index
65
65
  if width := _store(sheet, row, column, text, offset, rules):
@@ -79,6 +79,23 @@ def import_csv(
79
79
  return WriteResult(sheet=sheet.title, range=str(written), cells_written=cells)
80
80
 
81
81
 
82
+ def _too_many_cells(max_cells: int) -> LimitExceededError:
83
+ return LimitExceededError(
84
+ f"The file has more than {max_cells:,} cells, the limit per call. "
85
+ "Split the file or raise --max-cells."
86
+ )
87
+
88
+
89
+ def _lines_within(stream: TextIO, separator: str, max_cells: int) -> Iterator[str]:
90
+ """The lines of the file, refusing one with more delimiters than the limit has cells before
91
+ the reader makes a field of each: a record is held whole, so its length is not bounded by
92
+ the count of cells made so far."""
93
+ for line in stream:
94
+ if line.count(separator) >= max_cells:
95
+ raise _too_many_cells(max_cells)
96
+ yield line
97
+
98
+
82
99
  def _separator(sample: str, delimiter: str) -> str:
83
100
  """The delimiter named, or for 'auto' the most frequent usual one in the first record."""
84
101
  if delimiter != "auto":
@@ -8,6 +8,7 @@ from excel_mcp.operations.columns import find_column
8
8
  from excel_mcp.operations.comparison import FormulaResults, as_number, cell_value
9
9
  from excel_mcp.operations.sorting import save_cells, write_rows
10
10
  from excel_mcp.refs import CellRange
11
+ from excel_mcp.text_budget import TextBudget
11
12
 
12
13
 
13
14
  def remove_duplicates(
@@ -16,6 +17,7 @@ def remove_duplicates(
16
17
  columns: list[str] | None,
17
18
  has_header: bool,
18
19
  results: FormulaResults,
20
+ max_cells: int,
19
21
  ) -> int:
20
22
  """Move the rows to keep up, empty the rows left at the bottom, and return how many went.
21
23
 
@@ -35,12 +37,17 @@ def remove_duplicates(
35
37
  if columns
36
38
  else list(range(area.min_col, area.max_col + 1))
37
39
  )
40
+ budget = TextBudget(
41
+ max_cells,
42
+ "Comparing rows would read more than {limit} characters of text. Use fewer rows, or "
43
+ "raise --max-cells.",
44
+ )
38
45
  seen: set[tuple[object, ...]] = set()
39
46
  kept = []
40
47
  for cells in sheet.iter_rows(
41
48
  min_row=first_row, max_row=area.max_row, min_col=area.min_col, max_col=area.max_col
42
49
  ):
43
- key = tuple(_comparable(cells[col - area.min_col], results) for col in chosen)
50
+ key = tuple(_comparable(cells[col - area.min_col], results, budget) for col in chosen)
44
51
  if key not in seen:
45
52
  seen.add(key)
46
53
  kept.append(save_cells(cells))
@@ -60,12 +67,15 @@ def remove_duplicates(
60
67
  return removed
61
68
 
62
69
 
63
- def _comparable(cell: Cell | MergedCell, results: FormulaResults) -> tuple[str, object]:
70
+ def _comparable(
71
+ cell: Cell | MergedCell, results: FormulaResults, budget: TextBudget
72
+ ) -> tuple[str, object]:
64
73
  value = cell_value(cell, results)
65
74
  if isinstance(value, bool):
66
75
  return "boolean", value
67
76
  if (number := as_number(value)) is not None:
68
77
  return "number", number
69
78
  if isinstance(value, str):
79
+ budget.spend(len(value))
70
80
  return "text", value.casefold()
71
81
  return "blank", None
@@ -134,9 +134,14 @@ def _values(
134
134
  raise InvalidArgumentError("A date series starts from a date cell.")
135
135
  if step != int(step):
136
136
  raise InvalidArgumentError("A date series needs a whole-number step.")
137
- values: list[int | float | dt.date] = [
138
- _add_dates(seed, unit, int(step) * index) for index in range(count)
139
- ]
137
+ try:
138
+ values: list[int | float | dt.date] = [
139
+ _add_dates(seed, unit, int(step) * index) for index in range(count)
140
+ ]
141
+ except (OverflowError, ValueError):
142
+ raise InvalidArgumentError(
143
+ "The series goes past the last date a cell can hold."
144
+ ) from None
140
145
  numbers = [float(to_excel(value)) for value in values]
141
146
  else:
142
147
  if isinstance(seed, bool) or not isinstance(seed, int | float):
@@ -172,9 +177,16 @@ def _add_dates(seed: dt.date, unit: DateUnit, amount: int) -> dt.date:
172
177
 
173
178
 
174
179
  def _add_weekdays(seed: dt.date, amount: int) -> dt.date:
175
- direction = 1 if amount >= 0 else -1
180
+ """``amount`` weekdays on from ``seed``: whole weeks at once, then the few days left."""
181
+ if amount == 0:
182
+ return seed
183
+ direction = 1 if amount > 0 else -1
176
184
  result = seed
177
- for _ in range(abs(amount)):
185
+ while result.weekday() >= 5: # a weekend seed counts from the weekday it follows
186
+ result -= dt.timedelta(days=direction)
187
+ weeks, days = divmod(abs(amount), 5)
188
+ result += dt.timedelta(days=7 * weeks * direction)
189
+ for _ in range(days):
178
190
  result += dt.timedelta(days=direction)
179
191
  while result.weekday() >= 5:
180
192
  result += dt.timedelta(days=direction)
@@ -19,6 +19,7 @@ from excel_mcp.operations.sheet_view import ViewOptions, apply_view, move_sheet
19
19
  from excel_mcp.operations.spans import MAX_ROW_SPAN, parse_span, to_spans
20
20
  from excel_mcp.package.lines import Axis
21
21
  from excel_mcp.refs import MAX_ROW, parse_cell
22
+ from excel_mcp.text_budget import TextBudget
22
23
 
23
24
  MIN_AUTOFIT_WIDTH = 8
24
25
  MAX_AUTOFIT_WIDTH = 80
@@ -78,9 +79,12 @@ def apply_layout(
78
79
  if not 1 <= row <= MAX_ROW:
79
80
  raise InvalidArgumentError(f"Row {row} is outside 1 to {MAX_ROW}.")
80
81
  sheet.row_dimensions[row].height = height
81
- for column in layout.autofit_columns or []:
82
- letter = column_letter(column)
83
- sheet.column_dimensions[letter].width = estimate_width(sheet, letter)
82
+ if layout.autofit_columns:
83
+ fitted = estimate_widths(
84
+ sheet, [column_letter(c) for c in layout.autofit_columns], max_cells
85
+ )
86
+ for letter, width in fitted.items():
87
+ sheet.column_dimensions[letter].width = width
84
88
  if layout.freeze_panes is not None:
85
89
  parse_cell(layout.freeze_panes)
86
90
  sheet.freeze_panes = layout.freeze_panes.upper()
@@ -187,14 +191,23 @@ def column_letter(column: str) -> str:
187
191
  ) from None
188
192
 
189
193
 
190
- def estimate_width(sheet: Worksheet, letter: str) -> float:
191
- """Approximate a fitting width from the longest text line in the column."""
192
- index = column_index_from_string(letter)
193
- lines = [
194
- line
195
- for cell in stored_cells(sheet)
196
- if cell.column == index
197
- for line in str(cell.value).splitlines()
198
- ]
199
- longest = max((len(line) for line in lines), default=0)
200
- return min(max(longest + 2, MIN_AUTOFIT_WIDTH), MAX_AUTOFIT_WIDTH)
194
+ def estimate_widths(sheet: Worksheet, letters: list[str], max_cells: int) -> dict[str, float]:
195
+ """Approximate fitting widths from the longest text line in each column, in one pass over the
196
+ cells however many columns are asked for."""
197
+ budget = TextBudget(
198
+ max_cells,
199
+ "Fitting columns would read more than {limit} characters of text. Fit fewer columns, "
200
+ "or raise --max-cells.",
201
+ )
202
+ wanted = {column_index_from_string(letter): letter for letter in letters}
203
+ longest = dict.fromkeys(wanted, 0)
204
+ for cell in stored_cells(sheet):
205
+ if cell.column in wanted:
206
+ text = str(cell.value)
207
+ budget.spend(len(text))
208
+ lines = max(map(len, text.splitlines()), default=0)
209
+ longest[cell.column] = max(longest[cell.column], lines)
210
+ return {
211
+ letter: min(max(longest[index] + 2, MIN_AUTOFIT_WIDTH), MAX_AUTOFIT_WIDTH)
212
+ for index, letter in wanted.items()
213
+ }
@@ -32,7 +32,7 @@ from excel_mcp.operations.pivot_options import (
32
32
  PivotValue,
33
33
  ValuesIn,
34
34
  )
35
- from excel_mcp.operations.pivot_render import Shown, item_label, number_format, render
35
+ from excel_mcp.operations.pivot_render import Shown, item_label, measure, number_format, render
36
36
  from excel_mcp.operations.pivot_showas import Figures
37
37
  from excel_mcp.operations.pivot_source import Results, Source, read_source, serial
38
38
  from excel_mcp.operations.pivot_specs import (
@@ -103,19 +103,22 @@ def create_pivot(
103
103
  rows, columns, aggregator = _axes(
104
104
  source, calculated, specs, on_rows, on_columns, [*pages, *hidden], request
105
105
  )
106
- formats = [number_format(spec, _source_format(source, spec)) for spec in specs]
107
- figures = Figures(aggregator, rows, columns, specs)
108
- table = render(rows, columns, specs, figures, formats, request.layout)
106
+ # The table is as big as the items of its axes make it, which is not bounded by the source's
107
+ # cells (a few thousand records can make millions), so it is checked before it is built.
108
+ size = measure(rows, columns, request.layout)
109
109
  start_row, start_col = parse_cell(target_cell)
110
110
  filter_height = len(pages) + 1 if pages else 0
111
111
  body = CellRange(
112
112
  start_row + filter_height,
113
113
  start_col,
114
- start_row + filter_height + table.header_rows + table.rows - 1,
115
- start_col + table.label_columns + table.columns - 1,
114
+ start_row + filter_height + size.header_rows + size.rows - 1,
115
+ start_col + size.label_columns + size.columns - 1,
116
116
  )
117
117
  area = CellRange(start_row, start_col, body.max_row, body.max_col).within(limits.max_cells)
118
118
  _check_free(target_sheet, area, source_sheet, source.ref)
119
+ formats = [number_format(spec, _source_format(source, spec)) for spec in specs]
120
+ figures = Figures(aggregator, rows, columns, specs)
121
+ table = render(rows, columns, specs, figures, formats, request.layout)
119
122
 
120
123
  pivot_name = _pivot_name(workbook, request.name, copy_of_name)
121
124
  page_filters = [_page_filter(axis) for axis in pages]
@@ -26,7 +26,7 @@ ESCAPES = str.maketrans(
26
26
  "\t": "&#9;",
27
27
  }
28
28
  )
29
- BATCH = 10_000
29
+ BATCH_CHARACTERS = 1 << 20
30
30
 
31
31
 
32
32
  class SharedIndex(int):
@@ -57,9 +57,16 @@ class PivotRecords(RecordList):
57
57
 
58
58
  def _write_xml(stream: IO[bytes], rows: Sequence[Row]) -> None:
59
59
  stream.write(f'<pivotCacheRecords xmlns="{SHEET_MAIN_NS}" count="{len(rows)}">'.encode())
60
- for start in range(0, len(rows), BATCH):
61
- batch = rows[start : start + BATCH]
62
- stream.write("".join(f"<r>{''.join(_items(row))}</r>" for row in batch).encode())
60
+ batch: list[str] = []
61
+ size = 0
62
+ for row in rows:
63
+ record = f"<r>{''.join(_items(row))}</r>"
64
+ batch.append(record)
65
+ size += len(record)
66
+ if size >= BATCH_CHARACTERS:
67
+ stream.write("".join(batch).encode())
68
+ batch, size = [], 0
69
+ stream.write("".join(batch).encode())
63
70
  stream.write(b"</pivotCacheRecords>")
64
71
 
65
72
 
@@ -33,6 +33,13 @@ class Table:
33
33
  columns: int
34
34
 
35
35
 
36
+ def measure(rows: Axis, columns: Axis, layout: Layout) -> Table:
37
+ """The size of the table the axes make, before any of its cells are."""
38
+ label_columns = 1 if layout == "compact" else len(rows.fields) + rows.value_levels
39
+ header_rows = 1 + len(columns.fields) + columns.value_levels
40
+ return Table({}, header_rows, label_columns, len(rows.lines), len(columns.lines))
41
+
42
+
36
43
  def render(
37
44
  rows: Axis,
38
45
  columns: Axis,
@@ -42,9 +49,8 @@ def render(
42
49
  layout: Layout,
43
50
  ) -> Table:
44
51
  """``formats`` holds each values field's number format."""
45
- label_columns = 1 if layout == "compact" else len(rows.fields) + rows.value_levels
46
- header_rows = 1 + len(columns.fields) + columns.value_levels
47
- table = Table({}, header_rows, label_columns, len(rows.lines), len(columns.lines))
52
+ table = measure(rows, columns, layout)
53
+ header_rows, label_columns = table.header_rows, table.label_columns
48
54
  cells = table.cells
49
55
  _headers(cells, rows, columns, specs, layout, header_rows, label_columns)
50
56
  _column_labels(cells, columns, specs, label_columns)
@@ -17,6 +17,7 @@ from excel_mcp.lazy_workbook import LazyCells
17
17
  from excel_mcp.operations.comparison import FormulaResults
18
18
  from excel_mcp.refs import CellRange, cell_name, parse_range
19
19
  from excel_mcp.text import quoted
20
+ from excel_mcp.text_budget import TextBudget
20
21
 
21
22
  Value = str | int | float | bool | dt.datetime | ExcelError | None
22
23
  """A cell as a PivotTable sees it. Dates, times and durations are moments counted from `EPOCH`."""
@@ -95,9 +96,20 @@ def read_source(sheet: Worksheet, source_range: str, max_cells: int, results: Re
95
96
  )
96
97
  if area.rows < 2:
97
98
  raise InvalidArgumentError(f"source {area} needs a header row and at least one data row.")
99
+ # The cache stores a text once per record unless it is an item of an axis, so the text of the
100
+ # source is counted, not only its cells.
101
+ budget = TextBudget(
102
+ max_cells,
103
+ f"Source {area} has more than {{limit}} characters of text; PivotTables can read at most "
104
+ "that. Use a smaller source, or raise --max-pivot-cells (environment variable "
105
+ "EXCEL_MCP_MAX_PIVOT_CELLS).",
106
+ )
98
107
  if isinstance(sheet._cells, LazyCells) and (
99
108
  stored := sheet._cells.stored_rows(area.min_row, area.max_row)
100
109
  ):
110
+ budget.spend(
111
+ sum(len(c[1]) for row in stored for c in row.values() if isinstance(c[1], str))
112
+ )
101
113
  return _read_stored(sheet, area, stored)
102
114
  rows = [
103
115
  list(row)
@@ -105,6 +117,9 @@ def read_source(sheet: Worksheet, source_range: str, max_cells: int, results: Re
105
117
  min_row=area.min_row, max_row=area.max_row, min_col=area.min_col, max_col=area.max_col
106
118
  )
107
119
  ]
120
+ budget.spend(
121
+ sum(len(cell.value) for row in rows for cell in row if isinstance(cell.value, str))
122
+ )
108
123
  names = [_header(cell) for cell in rows[0]]
109
124
  folded = [name.strip().casefold() for name in names]
110
125
  if len(set(folded)) < len(folded):
@@ -6,8 +6,12 @@ from openpyxl.worksheet.worksheet import Worksheet
6
6
  from pydantic import BaseModel
7
7
 
8
8
  from excel_mcp import lazy_find
9
+ from excel_mcp.calc.values import MAX_TEXT
10
+ from excel_mcp.config import Limits
11
+ from excel_mcp.errors import LimitExceededError
9
12
  from excel_mcp.lazy_workbook import LazyCells
10
13
  from excel_mcp.operations.cells import parsed_cells, store_typed
14
+ from excel_mcp.text_budget import TextBudget
11
15
  from excel_mcp.workspace import sheet_names
12
16
  from excel_mcp.xlfn import display_formula
13
17
 
@@ -24,6 +28,7 @@ def replace_cells(
24
28
  exact: bool,
25
29
  case_sensitive: bool,
26
30
  in_formulas: bool,
31
+ limits: Limits,
27
32
  ) -> ReplaceResult:
28
33
  """Replace text in text, number and (optionally) formula cells; the result is retyped.
29
34
 
@@ -38,6 +43,14 @@ def replace_cells(
38
43
  def matches(text: str) -> bool:
39
44
  return found(text) is not None
40
45
 
46
+ grown = TextBudget(
47
+ limits.max_cells,
48
+ "Replacing would add more than {limit} characters of text. Replace in one sheet at a "
49
+ "time, with shorter text, or raise --max-cells.",
50
+ )
51
+ # A workbook can hold one long text and point millions of cells at it: each text is worked
52
+ # out once, and its result is shared by every cell that holds it.
53
+ results: dict[str, str | None] = {}
41
54
  replaced: dict[str, int] = {}
42
55
  for sheet in sheets:
43
56
  names = sheet_names(sheet)
@@ -48,11 +61,11 @@ def replace_cells(
48
61
  text = _searchable_text(cell.value, cell.data_type, in_formulas)
49
62
  if text is None:
50
63
  continue
51
- if exact:
52
- new_text = replacement if pattern.fullmatch(text) else text
53
- else:
54
- new_text = pattern.sub(lambda _: replacement, text)
55
- if new_text != text:
64
+ if text not in results:
65
+ new_text = _replaced(pattern, text, query, replacement, exact=exact)
66
+ grown.spend(max(0, len(new_text) - len(text)))
67
+ results[text] = None if new_text == text else new_text
68
+ if (new_text := results[text]) is not None:
56
69
  store_typed(cell, new_text, names)
57
70
  changed += 1
58
71
  if changed:
@@ -60,6 +73,29 @@ def replace_cells(
60
73
  return ReplaceResult(replaced=replaced)
61
74
 
62
75
 
76
+ def _replaced(
77
+ pattern: re.Pattern[str], text: str, query: str, replacement: str, *, exact: bool
78
+ ) -> str:
79
+ """``text`` with the matches replaced; refused, before it is built, if too long for a cell."""
80
+ if exact:
81
+ if pattern.fullmatch(text) is None:
82
+ return text
83
+ _fit(len(replacement))
84
+ return replacement
85
+ if len(replacement) > len(query):
86
+ found = list(pattern.finditer(text))
87
+ _fit(len(text) + sum(len(replacement) - f.end() + f.start() for f in found))
88
+ return pattern.sub(lambda _: replacement, text)
89
+
90
+
91
+ def _fit(size: int) -> None:
92
+ if size > MAX_TEXT:
93
+ raise LimitExceededError(
94
+ f"Replacing would make a cell longer than {MAX_TEXT:,} characters, the limit of "
95
+ "an Excel cell."
96
+ )
97
+
98
+
63
99
  def _searchable_text(value: object, data_type: str, in_formulas: bool) -> str | None:
64
100
  if data_type == "f":
65
101
  return display_formula(value) if in_formulas and isinstance(value, str) else None
@@ -80,7 +80,7 @@ def add_slicer(
80
80
  raise InvalidArgumentError(
81
81
  "A table slicer connects to its table only, and tables have no timelines."
82
82
  )
83
- return _table_slicer(workbook, sheet, owner, target, request, results), False
83
+ return _table_slicer(workbook, sheet, owner, target, request, limits, results), False
84
84
  pivots.share_caches(workbook)
85
85
  group = _connected(workbook, owner, target, request)
86
86
  if request.timeline:
@@ -94,6 +94,7 @@ def _table_slicer(
94
94
  owner: Worksheet,
95
95
  table: Table,
96
96
  request: SlicerRequest,
97
+ limits: Limits,
97
98
  results: Results,
98
99
  ) -> str:
99
100
  _check_style(request.style, "slicer")
@@ -101,6 +102,8 @@ def _table_slicer(
101
102
  _check_free_column(workbook, table, column.name)
102
103
  name, cache = _names(workbook, request, "Slicer_")
103
104
  if request.selected:
105
+ # The size of a table is whatever its file says, so it is checked like any range.
106
+ table_area(table).within(limits.max_cells)
104
107
  slicer_table.show_only(
105
108
  owner, table, column.name, request.selected, results(owner, table_area(table))
106
109
  )