feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille entiere et laissait le dernier-ecrivain gagner entre processus. - A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu - A11 : export de la selection + Markdown/HTML/impression et recherche sur toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable, retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille) - A14 : outils IA .xlsm/.csv + search_workbook, analyze_range, edit_xlsx_structure - tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34), TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
This commit is contained in:
+146
-14
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -65,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like
|
||||
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
|
||||
# first sheet used to eat the whole budget and hide a cached formula sitting in
|
||||
# the next one. The total ceiling still bounds the work on a many-sheet archive.
|
||||
# Running out of budget is reported as *unverified* (never as "nothing to lose")
|
||||
# so the write guard stays cautious instead of silently dropping the values.
|
||||
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
|
||||
_MAX_PROBE_BYTES_TOTAL = 32_000_000
|
||||
|
||||
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
|
||||
# and used to reload the whole workbook (normal mode, data_only=False) for every
|
||||
# window, just to read the style/merge/freeze maps of one sheet. The result is
|
||||
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
|
||||
_META_CACHE_MAX = 8
|
||||
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
@@ -111,6 +127,10 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -320,24 +340,65 @@ def _table(
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
def _is_sheet_xml(name: str) -> bool:
|
||||
"""True for the worksheet XML parts the cached-formula probe scans."""
|
||||
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
|
||||
|
||||
|
||||
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
|
||||
"""Scan the sheet XML for a formula carrying a non-empty cached result.
|
||||
|
||||
Returns ``(found, unverified)``. ``unverified`` is True when the byte
|
||||
budget stopped the scan before every sheet could be read to the end: a
|
||||
negative result is then **not** proof that the workbook holds no cached
|
||||
value (BUG-099), so callers must not treat it as a licence to write.
|
||||
|
||||
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
|
||||
single huge sheet can no longer starve the others) while
|
||||
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
|
||||
circuits the scan: the answer is already known.
|
||||
"""
|
||||
budget = _MAX_PROBE_BYTES_TOTAL
|
||||
unverified = False
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
if not _is_sheet_xml(name):
|
||||
continue
|
||||
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
|
||||
exhausted = False
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
while sheet_budget > 0:
|
||||
# Read at most what the sheet's allowance has left, so one
|
||||
# large chunk can never consume the whole total budget.
|
||||
chunk = fh.read(min(65536, sheet_budget))
|
||||
if not chunk:
|
||||
break
|
||||
break # read to the end: this sheet is verified clean
|
||||
sheet_budget -= len(chunk)
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
return True, False
|
||||
else:
|
||||
# Left the loop on the budget, not on EOF.
|
||||
exhausted = True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
if exhausted:
|
||||
unverified = True
|
||||
if budget <= 0:
|
||||
unverified = True
|
||||
break
|
||||
return False, unverified
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value.
|
||||
|
||||
Boolean view of :func:`_scan_cached_formulas` for the display path (the
|
||||
second ``data_only=True`` read): a truncated scan simply skips the shadow
|
||||
grid, it never claims the workbook is lossless.
|
||||
"""
|
||||
found, _ = _scan_cached_formulas(zf)
|
||||
return found
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
@@ -349,6 +410,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
|
||||
cached-value probe ran out of budget, so a negative result is not proof —
|
||||
the entry keeps the write guard cautious (409 + confirmation) rather than
|
||||
promising a lossless round-trip it cannot vouch for.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
@@ -358,13 +423,27 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
cached_found, cached_unverified = _scan_cached_formulas(zf)
|
||||
if cached_found:
|
||||
found.add("cached_values")
|
||||
elif cached_unverified:
|
||||
found.add("cached_values_unverified")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
"""Drop the cached metadata of one workbook (#156-A13).
|
||||
|
||||
Called by the write path right after the atomic replace: the (mtime, size)
|
||||
key already changes on a rewrite, this only closes the theoretical window
|
||||
where a same-size write lands on the same timestamp tick.
|
||||
"""
|
||||
with _meta_cache_lock:
|
||||
_meta_cache.pop(str(file_path), None)
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
|
||||
@@ -372,7 +451,31 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
so the viewer keeps its plain rendering. Styles are read with
|
||||
``data_only=False`` — the edited value is the formula, not its result.
|
||||
|
||||
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
|
||||
sheet is fetched window by window, and each window used to pay a full
|
||||
workbook load for these maps alone. Callers only read from the mapping.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(file_path)
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
with _meta_cache_lock:
|
||||
hit = _meta_cache.get(key)
|
||||
if hit and hit[0] == stamp:
|
||||
return hit[1]
|
||||
out = _read_workbook_meta_uncached(file_path)
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[key] = (stamp, out)
|
||||
while len(_meta_cache) > _META_CACHE_MAX:
|
||||
_meta_cache.pop(next(iter(_meta_cache)))
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load the workbook once and build the per-sheet metadata maps."""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
@@ -781,17 +884,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
|
||||
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
|
||||
# delimiter must be *detected* (then reused on write-back), not assumed.
|
||||
CSV_SNIFF_BYTES = 4096
|
||||
_CSV_DELIMITERS = (",", ";", "\t", "|")
|
||||
|
||||
|
||||
def sniff_csv_delimiter(raw: str) -> str:
|
||||
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
|
||||
|
||||
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
|
||||
unreliable on short or single-column samples, so the first non-empty line
|
||||
is counted as a tie-breaker and a comma remains the last resort.
|
||||
"""
|
||||
sample = raw[:CSV_SNIFF_BYTES]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
first = next((line for line in sample.splitlines() if line.strip()), "")
|
||||
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
|
||||
best = max(counts, key=lambda delim: counts[delim])
|
||||
return best if counts[best] else ","
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
|
||||
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
|
||||
|
||||
Row numbers replace the A1 column: a CSV has no fixed column count, so
|
||||
the first row is a plain data row like the others (the viewer offers the
|
||||
toolbar either way). Every cell is HTML-escaped at render time.
|
||||
|
||||
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
|
||||
BUG-098): a `;`-separated file used to render as a single column.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
reader = csv_mod.reader(
|
||||
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
|
||||
)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
|
||||
Reference in New Issue
Block a user