excel-mcp-server 2.2.0__tar.gz → 2.2.1__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.
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/PKG-INFO +4 -1
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/README.md +3 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/pyproject.toml +1 -1
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/pyproject.toml.orig +1 -1
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/formulas.py +20 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/lazy_edit.py +32 -30
- excel_mcp_server-2.2.1/src/excel_mcp/lazy_find.py +83 -0
- excel_mcp_server-2.2.1/src/excel_mcp/lazy_lines.py +259 -0
- excel_mcp_server-2.2.1/src/excel_mcp/lazy_rows.py +198 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/lazy_shared.py +49 -3
- excel_mcp_server-2.2.1/src/excel_mcp/lazy_source.py +102 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/lazy_workbook.py +123 -110
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/cells.py +25 -0
- excel_mcp_server-2.2.1/src/excel_mcp/operations/line_edit_styles.py +94 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/line_edits.py +28 -51
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot.py +6 -7
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_source.py +49 -1
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/replace.py +11 -2
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheet_copy.py +35 -8
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheet_refs.py +10 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/shifting.py +32 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/workbook_rewrite.py +6 -2
- excel_mcp_server-2.2.1/src/excel_mcp/plain_refs.py +106 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/workspace.py +1 -1
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/xlfn.py +4 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/LICENSE +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/__init__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/__main__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/cells.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/criteria.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/dependencies.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/dynamic.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/engine.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/__init__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/arrays.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/conditional.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/datetime.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/depreciation.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/distributions.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/financial.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/helpers.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/indirect.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/information.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/logical.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/lookup.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/math.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/regression.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/securities.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/statistical.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/subtotals.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/functions/text.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/operators.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/parser.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/r1c1.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/registry.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/scheduler.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/solver.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/steps.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/tablerefs.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/textformat.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/values.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/calc/wildcard.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/cfb.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/chart_roundtrip.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/cli.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/config.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/errors.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/formula_syntax.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/function_arguments.json +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/inputs.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/links.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/macros.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/__init__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/calculated.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chart_index.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chart_info.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/box_whisker_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/colors.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/funnel_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/histogram_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/sunburst_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/treemap_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_parts/waterfall_style.xml +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/chartex_xml.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_axes.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_check.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_data.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_look.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_modern.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_options.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_series.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/charts_style.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/columns.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/comparison.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/conditional.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/conditional_kinds.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/conditional_rule.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/conditional_x14.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/csv_export.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/csv_format.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/csv_import.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/csv_values.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/date_width.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/drawings.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/duplicates.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/files.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/fill.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/filter_criteria.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/filter_stored.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/filters.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/formatting.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/goal_seek.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/hyperlinks.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/images.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/inspect.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/layout.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/line_edit_objects.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/line_edit_rules.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/names.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/notes.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/paste.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_axis.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_cache.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_calc.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_definition.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_fields.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_groups.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_index.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_options.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_rebuild.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_records.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_render.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_showas.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_specs.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/pivot_values.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/print_setup.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/protection.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/rule_clear.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/rules.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheet_counts.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheet_shapes.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheet_view.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sheets.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_index.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_manage.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_pivot.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_place.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_table.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicer_xml.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/slicers.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sorting.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/spans.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sparkline_style.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sparkline_xml.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/sparklines.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/spill.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/table_options.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/tables.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/text_split.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/trace.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/transform.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/used_range.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/vba.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/vba_write.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/workbook_settings.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/operations/x14_xml.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/ovba.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/ovba_compress.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/ovba_write.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/__init__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/anchors.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/arrays.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/capture.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/capture_sheet.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/consistency.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/extensions.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/guards.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/lazy_records.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/lines.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/metadata.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/model.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/opc.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/patch.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/pivot_records.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/pivots.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/properties.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/reader.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/references.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/restore.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/restore_sheet.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/scan.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/shape_names.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/package/vml.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/paths.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/py.typed +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/refs.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/rewrite.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/__init__.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/annotation_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/areas.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/csv_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/data_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/format_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/formula_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/http.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/image_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/object_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/params.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/pivot_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/registry.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/results.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/sheet_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/slicer_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/validation.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/vba_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/vba_write_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/server/workbook_tools.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/spill.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/structured.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/text.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/upload_links.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/upload_scan.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/values.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/src/excel_mcp/xlm.py +0 -0
- {excel_mcp_server-2.2.0 → excel_mcp_server-2.2.1}/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.
|
|
3
|
+
Version: 2.2.1
|
|
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
|
|
@@ -98,6 +98,9 @@ Excel**, and runs on Windows, macOS and Linux.
|
|
|
98
98
|
**Large files**
|
|
99
99
|
- Reads stream the workbook and return pages, so memory stays flat on sheets with hundreds of
|
|
100
100
|
thousands of rows; every call is bounded by size and cell limits
|
|
101
|
+
- Edits of a sheet of 1 MB of XML or more parse only the rows they touch, so inserting or
|
|
102
|
+
deleting rows and columns, sorting, replacing, copying a sheet and creating a PivotTable
|
|
103
|
+
take seconds on a 200,000-row sheet instead of minutes
|
|
101
104
|
|
|
102
105
|
**Macros (opt-in)**
|
|
103
106
|
- Read the VBA code in `.xlsm` files. Writing VBA is off unless you start the server with
|
|
@@ -62,6 +62,9 @@ Excel**, and runs on Windows, macOS and Linux.
|
|
|
62
62
|
**Large files**
|
|
63
63
|
- Reads stream the workbook and return pages, so memory stays flat on sheets with hundreds of
|
|
64
64
|
thousands of rows; every call is bounded by size and cell limits
|
|
65
|
+
- Edits of a sheet of 1 MB of XML or more parse only the rows they touch, so inserting or
|
|
66
|
+
deleting rows and columns, sorting, replacing, copying a sheet and creating a PivotTable
|
|
67
|
+
take seconds on a 200,000-row sheet instead of minutes
|
|
65
68
|
|
|
66
69
|
**Macros (opt-in)**
|
|
67
70
|
- Read the VBA code in `.xlsm` files. Writing VBA is off unless you start the server with
|
|
@@ -16,10 +16,12 @@ else before a "!" (a file, a path, a URL, "[1]") points at another workbook.
|
|
|
16
16
|
|
|
17
17
|
import re
|
|
18
18
|
from collections.abc import Iterable, Iterator
|
|
19
|
+
from functools import lru_cache
|
|
19
20
|
|
|
20
21
|
from openpyxl.formula import Tokenizer
|
|
21
22
|
from openpyxl.formula.tokenizer import Token, TokenizerError
|
|
22
23
|
|
|
24
|
+
from excel_mcp import plain_refs
|
|
23
25
|
from excel_mcp.errors import InvalidFormulaError, UnsafeFormulaError
|
|
24
26
|
from excel_mcp.formula_syntax import check_syntax, invalid_formula
|
|
25
27
|
from excel_mcp.spill import store_spills
|
|
@@ -118,10 +120,28 @@ def storable_formula(formula: str, sheet_names: Iterable[str]) -> str:
|
|
|
118
120
|
|
|
119
121
|
Every formula that is written anywhere in a workbook goes through here.
|
|
120
122
|
"""
|
|
123
|
+
pattern = plain_refs.shape(formula)
|
|
124
|
+
if pattern is not None and _is_plain_and_allowed(pattern):
|
|
125
|
+
return formula
|
|
121
126
|
check_formula(formula, sheet_names)
|
|
122
127
|
return add_prefixes(store_spills(formula))
|
|
123
128
|
|
|
124
129
|
|
|
130
|
+
@lru_cache(maxsize=4096)
|
|
131
|
+
def _is_plain_and_allowed(pattern: str) -> bool:
|
|
132
|
+
"""Whether formulas of this shape pass the check and need no prefixes.
|
|
133
|
+
|
|
134
|
+
Which cells a plain formula refers to cannot change the outcome, so a sheet with hundreds
|
|
135
|
+
of thousands of formulas filled down from a few is checked a few times, not that often.
|
|
136
|
+
A formula that does not pass is checked again as itself, to raise its own error.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
check_formula(pattern, ())
|
|
140
|
+
except (InvalidFormulaError, UnsafeFormulaError):
|
|
141
|
+
return False
|
|
142
|
+
return add_prefixes(pattern) == pattern
|
|
143
|
+
|
|
144
|
+
|
|
125
145
|
def storable_operand(operand: str, sheet_names: Iterable[str]) -> str:
|
|
126
146
|
"""`storable_formula` for rule operands and names, which are stored without the "="."""
|
|
127
147
|
return storable_formula(f"={operand.removeprefix('=')}", sheet_names).removeprefix("=")
|
|
@@ -32,16 +32,9 @@ from openpyxl.worksheet.dimensions import DimensionHolder
|
|
|
32
32
|
from openpyxl.worksheet.worksheet import Worksheet
|
|
33
33
|
from openpyxl.xml.constants import SHARED_STRINGS
|
|
34
34
|
|
|
35
|
-
from excel_mcp import lazy_shared
|
|
36
|
-
from excel_mcp.
|
|
37
|
-
|
|
38
|
-
LazyCells,
|
|
39
|
-
Parts,
|
|
40
|
-
SheetSource,
|
|
41
|
-
lazy_sheets,
|
|
42
|
-
open_lazily,
|
|
43
|
-
split_sheet,
|
|
44
|
-
)
|
|
35
|
+
from excel_mcp import lazy_rows, lazy_shared
|
|
36
|
+
from excel_mcp.lazy_source import SheetSource, split_sheet
|
|
37
|
+
from excel_mcp.lazy_workbook import BLOCK_ROWS, LazyCells, Parts, lazy_sheets, open_lazily
|
|
45
38
|
from excel_mcp.package.opc import REL_BASE, Package, Rel, free_id
|
|
46
39
|
from excel_mcp.package.restore import MAIN, sheet_parts
|
|
47
40
|
from excel_mcp.package.scan import declare_in_root, root_scope
|
|
@@ -55,6 +48,7 @@ _ROW = re.compile(rb"<row\b[^>]*?/>|<row\b[^>]*>.*?</row>", re.S)
|
|
|
55
48
|
_ROW_NUMBER = re.compile(rb'\br="(\d+)"')
|
|
56
49
|
_DIMENSION = re.compile(rb'<dimension\s+ref="[^"]*"\s*/>')
|
|
57
50
|
_CELL_COLUMN = re.compile(rb'<c\b[^>]*?\br="([A-Z]+)\d')
|
|
51
|
+
_UNADDRESSED_CELL = re.compile(rb"<c\b(?![^>]*\br=)")
|
|
58
52
|
# A row with attributes of its own (a height, a style, hidden), not just its number.
|
|
59
53
|
_ATTRIBUTED_ROW = re.compile(rb"<row\b[^>]*\s(?!r=|spans=|[\w.-]+:)[\w.-]+=[^>]*>")
|
|
60
54
|
# What a formula stored as its result when Excel last calculated it, which an edit makes stale.
|
|
@@ -73,6 +67,8 @@ def _split(data: bytes) -> tuple[bytes, SheetSource] | None:
|
|
|
73
67
|
cross_cell = (b't="array"', b't="dataTable"', b' cm="', b' vm="')
|
|
74
68
|
if len(data) < LAZY_SHEET_BYTES or any(marker in data for marker in cross_cell):
|
|
75
69
|
return None
|
|
70
|
+
if _UNADDRESSED_CELL.search(data): # Its column is not known until the row is read.
|
|
71
|
+
return None
|
|
76
72
|
split = split_sheet(data)
|
|
77
73
|
return split if split and split[1].row_numbers else None
|
|
78
74
|
|
|
@@ -148,27 +144,31 @@ class LazyEdit:
|
|
|
148
144
|
|
|
149
145
|
path: Path
|
|
150
146
|
strings_part: str | None
|
|
151
|
-
|
|
152
|
-
frozen: dict[Worksheet, LazyCells] = field(default_factory=dict)
|
|
147
|
+
frozen: dict[Worksheet, tuple[LazyCells, dict[int, bytes]]] = field(default_factory=dict)
|
|
153
148
|
|
|
154
|
-
def freeze(self) -> None:
|
|
155
|
-
"""
|
|
149
|
+
def freeze(self, workbook: Workbook) -> None:
|
|
150
|
+
"""Prepare each sheet for saving, with the rows that were parsed written as text.
|
|
156
151
|
|
|
157
|
-
openpyxl writes what a sheet holds,
|
|
158
|
-
|
|
152
|
+
openpyxl writes what a sheet holds, which is only the rows that need it (those with a
|
|
153
|
+
link, a note or a value it writes in a form of its own). The other rows of blocks that
|
|
154
|
+
were parsed are written by `lazy_rows`, and the rows of blocks that were not parsed are
|
|
155
|
+
put in from the file; `merge` puts them all together.
|
|
159
156
|
"""
|
|
160
|
-
for sheet in
|
|
157
|
+
for sheet in workbook.worksheets:
|
|
161
158
|
cells = sheet._cells
|
|
162
|
-
if isinstance(cells, LazyCells):
|
|
163
|
-
|
|
159
|
+
if not isinstance(cells, LazyCells):
|
|
160
|
+
continue
|
|
161
|
+
lazy_shared.settle(cells)
|
|
164
162
|
dimensions = DimensionHolder(worksheet=sheet, default_factory=sheet._add_row)
|
|
165
163
|
for row, dimension in dict.items(sheet.row_dimensions):
|
|
166
|
-
if
|
|
164
|
+
if cells.is_loaded(row):
|
|
167
165
|
dimensions[row] = dimension
|
|
168
|
-
sheet.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
rendered, sheet._cells = lazy_rows.render(cells.held(), dimensions)
|
|
167
|
+
kept = {row for row, _ in sheet._cells}
|
|
168
|
+
sheet.row_dimensions = DimensionHolder(worksheet=sheet, default_factory=sheet._add_row)
|
|
169
|
+
for row in kept & dimensions.keys():
|
|
170
|
+
sheet.row_dimensions[row] = dimensions[row]
|
|
171
|
+
self.frozen[sheet] = (cells, rendered)
|
|
172
172
|
|
|
173
173
|
def merge(self, workbook: Workbook, content: bytes) -> bytes:
|
|
174
174
|
"""The saved workbook with the rows that were not parsed put back, as the file has them."""
|
|
@@ -179,8 +179,9 @@ class LazyEdit:
|
|
|
179
179
|
return content
|
|
180
180
|
package = Package(content)
|
|
181
181
|
parts = sheet_parts(package)
|
|
182
|
-
for sheet, cells in spliced.items():
|
|
183
|
-
|
|
182
|
+
for sheet, (cells, rendered) in spliced.items():
|
|
183
|
+
xml = _splice(package.read(parts[sheet.title]), cells, rendered)
|
|
184
|
+
package.write(parts[sheet.title], xml)
|
|
184
185
|
if self.strings_part:
|
|
185
186
|
self._restore_strings(package)
|
|
186
187
|
return package.to_bytes()
|
|
@@ -206,10 +207,10 @@ def load(path: Path) -> tuple[Workbook, LazyEdit | None]:
|
|
|
206
207
|
reader.read()
|
|
207
208
|
strings = reader.package.find(SHARED_STRINGS)
|
|
208
209
|
edit = LazyEdit(path, strings.PartName.lstrip("/") if strings is not None else None)
|
|
209
|
-
|
|
210
|
+
lazy = list(lazy_sheets(reader, parts))
|
|
211
|
+
for sheet, source in lazy:
|
|
210
212
|
_install(sheet, source, reader.shared_strings)
|
|
211
|
-
|
|
212
|
-
return reader.wb, edit if edit.sheets else None
|
|
213
|
+
return reader.wb, edit if lazy else None
|
|
213
214
|
|
|
214
215
|
|
|
215
216
|
def _install(sheet: Worksheet, source: SheetSource, strings: list[str]) -> None:
|
|
@@ -250,13 +251,14 @@ def _self_closed(tag: bytes) -> bytes:
|
|
|
250
251
|
return tag if tag.endswith(b"/>") else tag[:-1] + b"/>"
|
|
251
252
|
|
|
252
253
|
|
|
253
|
-
def _splice(xml: bytes, cells: LazyCells) -> bytes:
|
|
254
|
+
def _splice(xml: bytes, cells: LazyCells, rendered: dict[int, bytes]) -> bytes:
|
|
254
255
|
"""The sheet openpyxl wrote, holding the rows of the parsed blocks, with the others added."""
|
|
255
256
|
source = cells.source
|
|
256
257
|
data = _SHEET_DATA.search(xml)
|
|
257
258
|
if data is None:
|
|
258
259
|
raise ValueError("A saved worksheet has no sheetData.")
|
|
259
260
|
produced = {_row_number(row[0]): row[0] for row in _ROW.finditer(data[0])}
|
|
261
|
+
produced.update(rendered)
|
|
260
262
|
lazy_shared.restore(cells, produced)
|
|
261
263
|
pieces = _rows(cells, produced)
|
|
262
264
|
head, tail = xml[: data.start()], xml[data.end() :]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Finding which rows of a sheet that is not parsed can hold a text, without parsing them.
|
|
2
|
+
|
|
3
|
+
Replacing text looks at every cell, but most of a large sheet holds none of it. Only the blocks
|
|
4
|
+
of rows that might hold a match are parsed; the others are as in the file. "Might" is decided
|
|
5
|
+
from the XML and errs on the side of parsing: a block that holds nothing is parsed at worst.
|
|
6
|
+
A cell is searched as `replace` searches it: text, numbers as their text, formulas as they are
|
|
7
|
+
written (without the storage prefixes), so a formula that is only a master of the shared
|
|
8
|
+
formulas below it matches for the cells that follow it too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from bisect import bisect_right
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from html import unescape
|
|
15
|
+
|
|
16
|
+
from excel_mcp.lazy_workbook import LazyCells
|
|
17
|
+
|
|
18
|
+
_STRING = re.compile(rb'<c\b[^>]*\bt="s"[^>]*><v>(\d+)</v>')
|
|
19
|
+
_INLINE = re.compile(rb"<is>(.*?)</is>", re.S)
|
|
20
|
+
_TAGS = re.compile(rb"<[^>]*>")
|
|
21
|
+
_FORMULA = re.compile(rb"<f\b([^>]*)>([^<]*)</f>")
|
|
22
|
+
_NUMBER = re.compile(rb'<c\b(?![^>]*\bt="(?!n")\w+")[^>]*><v>')
|
|
23
|
+
_GROUP = re.compile(rb'\bsi="(\d+)"')
|
|
24
|
+
_TEXT_OF_NUMBERS = frozenset("0123456789.-+e")
|
|
25
|
+
# What a stored formula has and its text as shown does not, or the reverse.
|
|
26
|
+
_STORAGE_MARKS = ("_xl", "anchorarray")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_matches(
|
|
30
|
+
cells: LazyCells, matches: Callable[[str], bool], query: str, *, in_formulas: bool
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Parse the blocks that may hold a cell for which ``matches(text)`` is true.
|
|
33
|
+
|
|
34
|
+
``query`` is what ``matches`` looks for, which tells what kinds of cell it can match.
|
|
35
|
+
"""
|
|
36
|
+
source = cells.source
|
|
37
|
+
starts = [start for start, _ in source.spans]
|
|
38
|
+
rows: set[int] = set()
|
|
39
|
+
|
|
40
|
+
def mark(position: int) -> None:
|
|
41
|
+
rows.add(source.row_numbers[bisect_right(starts, position) - 1])
|
|
42
|
+
|
|
43
|
+
hits = {index for index, text in enumerate(cells.strings) if matches(text)}
|
|
44
|
+
if hits:
|
|
45
|
+
for found in _STRING.finditer(source.data):
|
|
46
|
+
if int(found[1]) in hits:
|
|
47
|
+
mark(found.start())
|
|
48
|
+
for found in _INLINE.finditer(source.data):
|
|
49
|
+
if matches(unescape(_TAGS.sub(b"", found[1]).decode())):
|
|
50
|
+
mark(found.start())
|
|
51
|
+
if set(query.lower()) <= _TEXT_OF_NUMBERS:
|
|
52
|
+
rows.update(_rows_with_numbers(cells))
|
|
53
|
+
if in_formulas:
|
|
54
|
+
_mark_formulas(cells, matches, query, mark)
|
|
55
|
+
for number in rows:
|
|
56
|
+
cells.load(number)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _rows_with_numbers(cells: LazyCells) -> list[int]:
|
|
60
|
+
source = cells.source
|
|
61
|
+
return [
|
|
62
|
+
number
|
|
63
|
+
for number, (start, stop) in zip(source.row_numbers, source.spans, strict=True)
|
|
64
|
+
if _NUMBER.search(source.data, start, stop)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _mark_formulas(
|
|
69
|
+
cells: LazyCells, matches: Callable[[str], bool], query: str, mark: Callable[[int], None]
|
|
70
|
+
) -> None:
|
|
71
|
+
source = cells.source
|
|
72
|
+
digits = any(character.isdigit() for character in query)
|
|
73
|
+
for found in _FORMULA.finditer(source.data):
|
|
74
|
+
shown = "=" + unescape(found[2].decode())
|
|
75
|
+
hit = matches(shown) or any(part in shown.lower() for part in _STORAGE_MARKS)
|
|
76
|
+
if hit:
|
|
77
|
+
mark(found.start())
|
|
78
|
+
group = _GROUP.search(found[1])
|
|
79
|
+
master = source.masters.get(group[1].decode()) if group else None
|
|
80
|
+
# A cell that follows its master differs from it in digits, and in letters when the
|
|
81
|
+
# group spans columns.
|
|
82
|
+
if master is not None and (hit or digits or master.wide):
|
|
83
|
+
cells.load_between(*master.rows)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Rows inserted or deleted, and references moved, in a sheet whose rows are not parsed.
|
|
2
|
+
|
|
3
|
+
A large sheet is held as the XML of its rows (`lazy_workbook`). Inserting rows above most of
|
|
4
|
+
them renumbers the rows and rewrites the references in their formulas, which needs no cells:
|
|
5
|
+
both are done on the XML, row by row, and only the rows around the edit are parsed.
|
|
6
|
+
|
|
7
|
+
Formulas filled down are stored as one master and the cells that follow it (`lazy_shared`).
|
|
8
|
+
They stay that way when every cell of the group moves alike: rewriting the master then
|
|
9
|
+
rewrites them all. A group whose cells or references fall on both sides of the edit (such a
|
|
10
|
+
cell now refers to another one than its master does) is parsed first and becomes ordinary
|
|
11
|
+
formulas, as Excel splits it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from html import unescape
|
|
19
|
+
from typing import Any
|
|
20
|
+
from xml.sax.saxutils import escape
|
|
21
|
+
|
|
22
|
+
from openpyxl.formula.translate import Translator
|
|
23
|
+
from openpyxl.utils import column_index_from_string, get_column_letter
|
|
24
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
25
|
+
|
|
26
|
+
from excel_mcp.formulas import storable_formula
|
|
27
|
+
from excel_mcp.lazy_shared import SharedMaster
|
|
28
|
+
from excel_mcp.lazy_source import SheetSource
|
|
29
|
+
from excel_mcp.lazy_workbook import CellKey, LazyCells
|
|
30
|
+
from excel_mcp.package.lines import LineEdit
|
|
31
|
+
from excel_mcp.refs import parse_range
|
|
32
|
+
from excel_mcp.rewrite import ReferenceRewriter
|
|
33
|
+
|
|
34
|
+
_FORMULA = re.compile(rb"<f\b([^>]*)>([^<]*)</f>")
|
|
35
|
+
_ROW_NUMBERS = re.compile(rb'(<row\b[^>]*?\br="|<c\b[^>]*?\br="[A-Z]+)(\d+)(")')
|
|
36
|
+
_REF = re.compile(rb' ref="([^"]*)"')
|
|
37
|
+
_CELLS = re.compile(rb"<c\b[^>]*?(?:/>|>.*?</c>)", re.S)
|
|
38
|
+
_CELL_REF = re.compile(rb'<c\b[^>]*?\br="([A-Z]+)(\d+)"')
|
|
39
|
+
_STYLE = re.compile(rb' s="(\d+)"')
|
|
40
|
+
_SPANS = re.compile(rb' spans="[^"]*"')
|
|
41
|
+
|
|
42
|
+
Row = tuple[int, bytes]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def unshare(cells: LazyCells, host: str, rewriter: ReferenceRewriter, edit: LineEdit) -> None:
|
|
46
|
+
"""Parse the groups of shared formulas that ``edit`` would not leave alike."""
|
|
47
|
+
for master in cells.source.masters.values():
|
|
48
|
+
if not _moves_alike(master, host, rewriter, edit):
|
|
49
|
+
cells.load_between(*master.rows)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _moves_alike(
|
|
53
|
+
master: SharedMaster, host: str, rewriter: ReferenceRewriter, edit: LineEdit
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""Whether every cell of the group ends up with the formula its master gives it.
|
|
56
|
+
|
|
57
|
+
The group moves alike if the master's rewritten formula, copied to the farthest cell of the
|
|
58
|
+
group, is what rewriting the formula of that cell gives. The shift of a reference is the
|
|
59
|
+
same for the cells in between, because it changes only once, at the edit.
|
|
60
|
+
"""
|
|
61
|
+
area = parse_range(master.ref)
|
|
62
|
+
if host.casefold() == edit.sheet.casefold():
|
|
63
|
+
first, last = (
|
|
64
|
+
(area.min_row, area.max_row) if edit.axis == "rows" else (area.min_col, area.max_col)
|
|
65
|
+
)
|
|
66
|
+
if edit.cuts(first, last):
|
|
67
|
+
return False
|
|
68
|
+
far = f"{get_column_letter(area.max_col)}{area.max_row}"
|
|
69
|
+
formula = "=" + master.text
|
|
70
|
+
copied = Translator(formula, master.coordinate).translate_formula(far)
|
|
71
|
+
moved = Translator(rewriter.formula(formula, host), master.coordinate).translate_formula(far)
|
|
72
|
+
return moved == rewriter.formula(copied, host)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rewrite_formulas(
|
|
76
|
+
cells: LazyCells,
|
|
77
|
+
host: str,
|
|
78
|
+
rewriter: ReferenceRewriter,
|
|
79
|
+
names: list[str],
|
|
80
|
+
*,
|
|
81
|
+
check_all: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Rewrite the references of the formulas in the rows that are not parsed.
|
|
84
|
+
|
|
85
|
+
A formula that changes is checked as every written formula is; ``check_all`` also checks
|
|
86
|
+
those that stay as they are (a copy of a sheet writes them all).
|
|
87
|
+
"""
|
|
88
|
+
source = cells.source
|
|
89
|
+
cache: dict[str, str] = {}
|
|
90
|
+
|
|
91
|
+
def rewrite(text: str) -> str:
|
|
92
|
+
if text not in cache:
|
|
93
|
+
formula = "=" + text
|
|
94
|
+
moved = rewriter.formula(formula, host)
|
|
95
|
+
unchanged = moved == formula and not check_all
|
|
96
|
+
cache[text] = text if unchanged else storable_formula(moved, names)[1:]
|
|
97
|
+
return cache[text]
|
|
98
|
+
|
|
99
|
+
def replace_formula(match: re.Match[bytes]) -> bytes:
|
|
100
|
+
text = unescape(match[2].decode())
|
|
101
|
+
moved = rewrite(text)
|
|
102
|
+
if moved == text:
|
|
103
|
+
return match[0]
|
|
104
|
+
return b"<f%b>%b</f>" % (match[1], escape(moved).encode())
|
|
105
|
+
|
|
106
|
+
rows: list[Row] = []
|
|
107
|
+
for number, (start, stop) in zip(source.row_numbers, source.spans, strict=True):
|
|
108
|
+
row = source.data[start:stop]
|
|
109
|
+
if b"<f" in row and not cells.is_loaded(number):
|
|
110
|
+
row = _FORMULA.sub(replace_formula, row)
|
|
111
|
+
rows.append((number, row))
|
|
112
|
+
masters = {group: replace(m, text=rewrite(m.text)) for group, m in source.masters.items()}
|
|
113
|
+
cells.source = _assembled(source, rows, masters, source.hidden_rows)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def move_lines(
|
|
117
|
+
cells: LazyCells,
|
|
118
|
+
edit: LineEdit,
|
|
119
|
+
moved: dict[CellKey, Any],
|
|
120
|
+
inherit: Callable[[int, int | None], int] | None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Insert or delete rows or columns: move the rows that are not parsed, in their XML, and
|
|
123
|
+
hold ``moved`` (the cells that are parsed, already moved) with them.
|
|
124
|
+
|
|
125
|
+
``inherit`` gives the style of a cell that is inserted next to one: from the style of that
|
|
126
|
+
cell and of the one on its other side.
|
|
127
|
+
"""
|
|
128
|
+
source = cells.source
|
|
129
|
+
rows: list[Row] = []
|
|
130
|
+
hidden = source.hidden_rows
|
|
131
|
+
parsed = cells.parsed_rows()
|
|
132
|
+
if edit.axis == "rows":
|
|
133
|
+
move_row = _row_mover(edit)
|
|
134
|
+
for number, (start, stop) in zip(source.row_numbers, source.spans, strict=True):
|
|
135
|
+
if (index := edit.index(number)) is not None and not cells.is_loaded(number):
|
|
136
|
+
rows.append((index, move_row(source.data[start:stop], index - number)))
|
|
137
|
+
hidden = [index for row in hidden if (index := edit.index(row)) is not None]
|
|
138
|
+
parsed = [span for first, last in parsed if (span := edit.span(first, last))]
|
|
139
|
+
else:
|
|
140
|
+
move_cells = _column_mover(edit, inherit)
|
|
141
|
+
for number, (start, stop) in zip(source.row_numbers, source.spans, strict=True):
|
|
142
|
+
if not cells.is_loaded(number):
|
|
143
|
+
rows.append((number, move_cells(source.data[start:stop])))
|
|
144
|
+
cells.replace(moved, _assembled(source, rows, _moved_masters(source, edit), hidden), parsed)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def copied(cells: LazyCells, sheet: Worksheet) -> LazyCells:
|
|
148
|
+
"""The cells of a copy of a sheet: the rows that are not parsed are shared with the original,
|
|
149
|
+
which keeps the cells that are parsed."""
|
|
150
|
+
source = cells.source
|
|
151
|
+
rows = [
|
|
152
|
+
(number, source.data[start:stop])
|
|
153
|
+
for number, (start, stop) in zip(source.row_numbers, source.spans, strict=True)
|
|
154
|
+
if not cells.is_loaded(number)
|
|
155
|
+
]
|
|
156
|
+
masters = dict(source.masters)
|
|
157
|
+
return LazyCells(sheet, _assembled(source, rows, masters, source.hidden_rows), cells.strings)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _moved_masters(source: SheetSource, edit: LineEdit) -> dict[str, SharedMaster]:
|
|
161
|
+
masters = {}
|
|
162
|
+
rows = edit.axis == "rows"
|
|
163
|
+
for group, master in source.masters.items():
|
|
164
|
+
area = edit.range(parse_range(master.ref))
|
|
165
|
+
line = edit.index(master.row if rows else master.column)
|
|
166
|
+
if line is not None and area is not None:
|
|
167
|
+
position = {"row": line} if rows else {"column": line}
|
|
168
|
+
masters[group] = replace(master, ref=str(area), **position)
|
|
169
|
+
return masters
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ref_mover(edit: LineEdit) -> Callable[[re.Match[bytes]], bytes]:
|
|
173
|
+
"""What a shared formula's ``ref`` becomes."""
|
|
174
|
+
|
|
175
|
+
def move(match: re.Match[bytes]) -> bytes:
|
|
176
|
+
area = edit.range(parse_range(match[1].decode()))
|
|
177
|
+
return b' ref="%b"' % str(area).encode()
|
|
178
|
+
|
|
179
|
+
return move
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _row_mover(edit: LineEdit) -> Callable[[bytes, int], bytes]:
|
|
183
|
+
"""A function that moves a row of XML down by some rows: its number, its cells, and the
|
|
184
|
+
range of a shared formula it holds."""
|
|
185
|
+
move_ref = _ref_mover(edit)
|
|
186
|
+
|
|
187
|
+
def move(row: bytes, delta: int) -> bytes:
|
|
188
|
+
if not delta:
|
|
189
|
+
return row
|
|
190
|
+
row = _ROW_NUMBERS.sub(lambda m: m[1] + b"%d" % (int(m[2]) + delta) + m[3], row)
|
|
191
|
+
return _REF.sub(move_ref, row) if b" ref=" in row else row
|
|
192
|
+
|
|
193
|
+
return move
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _column_mover(
|
|
197
|
+
edit: LineEdit, inherit: Callable[[int, int | None], int] | None
|
|
198
|
+
) -> Callable[[bytes], bytes]:
|
|
199
|
+
"""A function that moves the cells of a row of XML sideways: it drops those in deleted
|
|
200
|
+
columns, renames the others and gives the cells inserted next to one its format."""
|
|
201
|
+
move_ref = _ref_mover(edit)
|
|
202
|
+
|
|
203
|
+
def move(row: bytes) -> bytes:
|
|
204
|
+
found = list(_CELLS.finditer(row))
|
|
205
|
+
if not found:
|
|
206
|
+
return row
|
|
207
|
+
cells = []
|
|
208
|
+
for match in found:
|
|
209
|
+
ref = _CELL_REF.match(match[0])
|
|
210
|
+
assert ref is not None # `split_sheet` leaves out sheets with cells without an address.
|
|
211
|
+
cells.append((_column(ref[1]), ref, match[0]))
|
|
212
|
+
styles = {column: _style(cell) for column, _, cell in cells} if inherit else {}
|
|
213
|
+
kept = []
|
|
214
|
+
for column, ref, cell in cells:
|
|
215
|
+
line = edit.index(column)
|
|
216
|
+
if line is None:
|
|
217
|
+
continue
|
|
218
|
+
if line != column:
|
|
219
|
+
cell = cell[: ref.start(1)] + _letters(line) + cell[ref.end(1) :]
|
|
220
|
+
cell = _REF.sub(move_ref, cell) if b" ref=" in cell else cell
|
|
221
|
+
kept.append(cell)
|
|
222
|
+
if inherit and column == edit.at - 1:
|
|
223
|
+
style = inherit(styles[column], styles.get(edit.at))
|
|
224
|
+
kept.extend(
|
|
225
|
+
b'<c r="%b%b" s="%d"/>' % (_letters(new), ref[2], style)
|
|
226
|
+
for new in range(edit.at, edit.at + edit.count)
|
|
227
|
+
if style
|
|
228
|
+
)
|
|
229
|
+
head = _SPANS.sub(b"", row[: found[0].start()])
|
|
230
|
+
return head + b"".join(kept) + row[found[-1].end() :]
|
|
231
|
+
|
|
232
|
+
return move
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@lru_cache(maxsize=1024)
|
|
236
|
+
def _column(letters: bytes) -> int:
|
|
237
|
+
return column_index_from_string(letters.decode())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@lru_cache(maxsize=1024)
|
|
241
|
+
def _letters(column: int) -> bytes:
|
|
242
|
+
return get_column_letter(column).encode()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _style(cell: bytes) -> int:
|
|
246
|
+
found = _STYLE.search(cell, 0, cell.index(b">"))
|
|
247
|
+
return int(found[1]) if found else 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _assembled(
|
|
251
|
+
source: SheetSource, rows: list[Row], masters: dict[str, SharedMaster], hidden: list[int]
|
|
252
|
+
) -> SheetSource:
|
|
253
|
+
spans = []
|
|
254
|
+
offset = 0
|
|
255
|
+
for _, row in rows:
|
|
256
|
+
spans.append((offset, offset + len(row)))
|
|
257
|
+
offset += len(row)
|
|
258
|
+
data = b"".join(row for _, row in rows)
|
|
259
|
+
return SheetSource(data, source.root, [number for number, _ in rows], spans, hidden, masters)
|