Repository navigation
Expand file tree
/
Copy pathfunction.py
More file actions
1619 lines (1394 loc) · 71.9 KB
/
Copy pathfunction.py
File metadata and controls
1619 lines (1394 loc) · 71.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2024 John Balis
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""=PY() execution and return helpers (venv worker); no LLM imports."""
from __future__ import annotations
from contextlib import contextmanager
import datetime
import logging
import math
import re
import threading
import time
from typing import Any, ClassVar, Iterator, cast
from plugin.calc.calc_addin_data import calc_addin_args_from_split, check_python_data_size, check_python_multi_data_size, count_cells, pack_calc_data_for_wire, pack_calc_multi_data_for_wire, split_python_addin_data_args
from plugin.calc.datetime_wire import coalesce_temporal_apply_rects, duration_serial_from_iso, match_iso_duration, match_iso_temporal, should_preserve_temporal_format
from plugin.calc.inspector import _format_category_from_type
from plugin.calc.python.formula_locator_cache import is_matching_py_formula, locate_formula_cell_in_doc
from plugin.calc.python.image_egress import insert_image_result_on_sheet
from plugin.framework.errors import format_error_message
from plugin.framework.i18n import _
from plugin.framework.thread_guard import sync_host_dispatch
from plugin.scripting.config_limits import configured_python_max_data_cells
from plugin.scripting.payload_codec import is_dataframe_payload, is_split_grid, find_image_payloads
from plugin.scripting.calc_range import dataframe_to_labeled_grid
from plugin.scripting.session_manager import workbook_session_id
from plugin.scripting.venv_worker import run_code_in_user_venv
log = logging.getLogger(__name__)
# Calc legacy add-in bridge accepts scalar double/string returns only. List results are
# emitted one scalar per formula evaluation (matrix block or repeated recalc).
# Keys include repr(worker_data) so the same formula with different data args
# does not share a session. repr of a large grid is expensive and a weak identity;
# a later change could use packed-payload digest + cell count. Do not key on id():
# recals would collide. Two formulas with the same code but different data must
# stay on separate sessions (see tests/calc/python/test_function.py).
_MATRIX_SCALAR_SESSIONS_LOCK = threading.Lock()
_MATRIX_SCALAR_SESSIONS: dict[tuple[int, tuple[str, ...], str], WorkerResultSession] = {}
# Recalc-clump timings for DEBUG ``py_timing`` lines (not asctime deltas).
# Flip to True in this file when measuring workbook-open / recalc cost; leave False in commits.
PYTHON_TIMINGS_LOG = False
_PY_PASS_STATS = threading.local()
_PY_PASS_GAP_SEC = 2.0
_PY_HELPER_IN_SPEC_RE = re.compile(r"""["']helper["']\s*:\s*["'](\w+)["']""")
def flatten_result_values(result: Any) -> list[Any]:
"""Row-major flattening for list / nested list worker results."""
if not isinstance(result, (list, tuple)):
return [result]
if not result:
return []
if any(isinstance(row, (list, tuple)) for row in result):
flat: list[Any] = []
for row in result:
if isinstance(row, (list, tuple)):
flat.extend(row)
else:
flat.append(row)
return flat
return list(result)
def is_scalar_index_arg(py_data: list[Any] | list[list[Any]] | None) -> bool:
"""True when arg 1 is one number (matrix index), not a data range."""
if py_data is None:
return False
if count_cells(py_data) != 1:
return False
val = _unwrap_single_cell(py_data)
return isinstance(val, (int, float)) and not isinstance(val, bool) and not math.isnan(val)
def _unwrap_single_cell(py_data: Any) -> Any:
"""Unwrap ``[[v]]`` / ``[v]`` / scalar to the inner value."""
val = py_data
while isinstance(val, list) and len(val) == 1:
val = val[0]
return val
def _host_ndarray_as_list(value: Any) -> list[Any] | None:
"""Turn a NumPy array into a nested list without importing NumPy on the host.
Bugfix: a small numeric result used to stay an ndarray across the pipe.
``float()`` on a multi-cell array fails, and ``to_calc_compatible`` then
returned the array's text. ``tolist`` is the array method. Pandas objects
are left alone (their module is not ``numpy``).
"""
if isinstance(value, (str, bytes, bytearray, list, tuple, dict)) or value is None:
return None
module = getattr(type(value), "__module__", "")
if not (isinstance(module, str) and (module == "numpy" or module.startswith("numpy."))):
return None
tolist = getattr(value, "tolist", None)
if not callable(tolist):
return None
try:
listed = tolist()
except Exception:
log.debug("result_to_calc_grid: ndarray tolist failed", exc_info=True)
return None
if isinstance(listed, list):
return listed
return None
def result_to_calc_grid(result: Any, *, include_dataframe_header: bool = True) -> Any:
"""Normalize worker results for Calc consumers.
DataFrame envelopes become a labeled 2D grid (header row + body) by default.
Lists pass through. A leftover ndarray body is converted with ``tolist``.
"""
if is_dataframe_payload(result):
cols = list(result.get("columns") or [])
data = result.get("data")
if not isinstance(data, list):
listed = _host_ndarray_as_list(data)
data = listed if listed is not None else []
return dataframe_to_labeled_grid(cols, data, include_header=include_dataframe_header)
listed = _host_ndarray_as_list(result)
if listed is not None:
return listed
return result
def coerce_index(value: Any) -> int:
if value is None:
return 0
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str) and value.strip():
return int(float(value))
raise ValueError(f"index must be numeric, got {value!r}")
def _calc_iso_datetime(dt: datetime.datetime) -> str:
"""Naive ISO-8601. Calc does not parse offset-bearing stamps as dates."""
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt.isoformat()
def to_calc_compatible(val: Any) -> float | str | tuple[Any, ...]:
"""Recursively convert Python values into LibreOffice Calc supported types.
Calc cells and matrix formulas only support float (UNO double) and str (UNO string).
Crucially, Calc matrix formulas do NOT support integer (UNO long) types and will
throw #VALUE! if a sequence contains integers/longs. Python booleans are converted
to 1.0 / 0.0 (UNO double) because Calc's Add-In bridge only unpacks doubles and strings.
Host LibreOffice Python has no pandas/numpy — temporal pandas types are duck-typed
(Timestamp subclasses datetime; NaT is NaTType). Do not import pandas here.
"""
if val is None:
return ""
# pd.NaT subclasses datetime but isoformat() raises; map missing to empty cell.
tname = type(val).__name__
if tname in ("NaTType", "NAType"):
return ""
# Bugfix (#413): When Python bool (True/False) was returned directly, PyUNO wrapped it in
# uno::Any with TypeClass_BOOLEAN. LibreOffice Calc's C++ Add-In caller (ScUnoAddInCall)
# only unpacks double and string types, silently defaulting unhandled types (including BOOLEAN)
# to 0.0. Mapping bool to 1.0 / 0.0 allows Calc formulas (e.g. IF, logical operators) to evaluate
# truthiness correctly and matches _coerce_spill_value.
if isinstance(val, bool):
return 1.0 if val else 0.0
if isinstance(val, int):
return float(val)
if isinstance(val, float):
# Computed NaN (or NaN from a numeric grid that contained blanks) is returned as-is.
# The Calc add-in bridge renders a raw NaN double as a cascading error (#NUM! or #VALUE!).
# Python None is mapped to "" (empty cell). We intentionally do NOT collapse NaN here.
# ±inf passes through (may also error in formulas). Do not collapse inf to empty.
return val
if isinstance(val, str):
return val
if isinstance(val, datetime.datetime):
return _calc_iso_datetime(val)
if isinstance(val, datetime.date):
return val.isoformat()
if isinstance(val, datetime.time):
return val.isoformat()
if isinstance(val, datetime.timedelta):
# In Calc, time intervals are represented as fractional days (e.g. 1.0 = 24 hours)
return val.total_seconds() / 86400.0
# np.datetime64 / timedelta64 (duck-typed; host may see these only if venv conversion was skipped)
kind = getattr(getattr(val, "dtype", None), "kind", None)
if kind == "M":
text = str(val)
return "" if text == "NaT" else text
if kind == "m":
to_pytd = getattr(val, "item", None)
if callable(to_pytd):
try:
item = to_pytd()
if isinstance(item, datetime.timedelta):
return item.total_seconds() / 86400.0
except (ValueError, TypeError, OverflowError):
pass
text = str(val)
return "" if text in ("NaT", "NaTType") else text
to_pydt = getattr(val, "to_pydatetime", None)
if callable(to_pydt):
try:
dt = to_pydt()
if isinstance(dt, datetime.datetime):
return _calc_iso_datetime(dt)
except (ValueError, TypeError):
pass
to_pytd = getattr(val, "to_pytimedelta", None)
if callable(to_pytd):
try:
td = to_pytd()
if isinstance(td, datetime.timedelta):
return td.total_seconds() / 86400.0
except (ValueError, TypeError):
pass
if hasattr(val, "__float__") and not isinstance(val, (bytes, list, tuple, dict, set)):
try:
f = float(val) # type: ignore[arg-type]
return f
except (ValueError, TypeError, OverflowError):
pass
if isinstance(val, (list, tuple)):
if not val:
return ()
# Check if 2D sequence (contains nested rows)
if any(isinstance(row, (list, tuple)) for row in val):
# Normalize each row to a list of elements
rows: list[list[Any]] = [list(row) if isinstance(row, (list, tuple)) else [row] for row in val]
max_cols = max(len(row) for row in rows) if rows else 0
# Rectangularize by padding shorter rows with "" so Calc matrix receives a valid rectangular grid
padded_rows = []
for row in rows:
padded = [to_calc_compatible(cell) for cell in row]
if len(padded) < max_cols:
padded.extend([""] * (max_cols - len(padded)))
padded_rows.append(tuple(padded))
return tuple(padded_rows)
return tuple(to_calc_compatible(item) for item in val)
return str(val)
def _get_calc_doc(ctx: Any) -> Any | None:
try:
from plugin.framework.thread_guard import guard_uno, on_main_thread
if not on_main_thread():
return None
from plugin.framework.uno_context import get_desktop
desktop = get_desktop(ctx)
doc = desktop.getCurrentComponent()
if doc is not None and hasattr(doc, "getSheets"):
return guard_uno(doc)
comps = desktop.getComponents()
if comps is not None and hasattr(comps, "createEnumeration"):
enum = comps.createEnumeration()
while enum and enum.hasMoreElements():
elem = enum.nextElement()
model = None
if hasattr(elem, "getURL") and callable(getattr(elem, "getURL")):
model = elem
elif hasattr(elem, "getController") and getattr(elem, "getController", lambda: None)():
ctrl = elem.getController()
model = ctrl.getModel() if hasattr(ctrl, "getModel") else None
if model and hasattr(model, "getSheets"):
return guard_uno(model)
except Exception:
log.debug("_get_calc_doc lookup failed", exc_info=True)
return None
def session_key(ctx: Any, code: str, doc: Any | None = None) -> tuple[str, ...]:
# Bugfix (#402, #411): Include workbook session_id in key so unsaved documents
# (where doc_url="") do not collide in the in-memory formula result cache.
# Do not use getActiveSheet(): full recalc's active sheet is not the formula cell
# (XAddIn has no calling cell). Unique locate fills sheet+origin; otherwise
# callers must not share WorkerResultSession.
from plugin.framework.thread_guard import on_main_thread
# Bugfix: off-main finalize hands scalar_for_list_result the cached spill
# model (the object a deferred write posts to the UI thread). The guard
# below only ran when doc was None, so getURL and locate_formula_cell_in_doc
# ran on that model from a Yellow thread. Skip UNO off-main; the key stays
# ambiguous and WorkerResultSession is not shared until the UI thread locates.
if not on_main_thread():
return ("", "", "", code, "")
doc_url = ""
sheet_name = ""
sid = ""
origin = ""
try:
# The calling document comes only from the add-in caller argument.
target = doc
if target is not None:
url_val = getattr(target, "getURL", lambda: "")()
doc_url = url_val if isinstance(url_val, str) else ""
from plugin.scripting.session_manager import workbook_session_id
sid = workbook_session_id(ctx, doc=target) or ""
located = locate_formula_cell_in_doc(ctx, target, code)
if located is not None:
sheet, _cell, coord = located
name_val = getattr(sheet, "getName", lambda: "")()
sheet_name = name_val if isinstance(name_val, str) else ""
origin = f"{coord[0]},{coord[1]}"
except Exception:
log.debug("session_key inline metadata lookup exception", exc_info=True)
return (doc_url, sheet_name, sid, code, origin)
class WorkerResultSession:
"""Caches one worker list result across multiple =PY() calls in a recalc pass."""
__slots__: ClassVar[tuple[str, ...]] = ("raw", "flat", "next_index", "timestamp")
raw: Any
flat: tuple[Any, ...]
next_index: int
timestamp: float
def __init__(self, raw: Any, flat: list[Any], timestamp: float | None = None) -> None:
self.raw = raw
self.flat = tuple(flat)
self.next_index = 0
self.timestamp = time.monotonic() if timestamp is None else timestamp
def scalar_for_list_result(ctx: Any, code: str, result: Any, *, worker_data: Any = None, doc: Any | None = None) -> float | str | bool:
"""Return one Calc scalar per invocation when the worker produced a list."""
flat: list[Any] = [to_calc_compatible(v) for v in flatten_result_values(result)]
if not flat:
return ""
tid = threading.get_ident()
sk = session_key(ctx, code, doc=doc)
if not sk[4]:
# Ambiguous formula identity: do not share next_index across duplicate =PY() cells.
return flat[0] if flat else ""
key = (tid, sk, repr(worker_data))
now = time.monotonic()
with _MATRIX_SCALAR_SESSIONS_LOCK:
state = _MATRIX_SCALAR_SESSIONS.get(key)
if (
not isinstance(state, WorkerResultSession)
or state.flat != tuple(flat)
or (now - state.timestamp) > _PY_PASS_GAP_SEC
):
state = WorkerResultSession(result, flat, timestamp=now)
_MATRIX_SCALAR_SESSIONS[key] = state
state.timestamp = now
idx = state.next_index
state.next_index = idx + 1
if state.next_index >= len(state.flat):
_MATRIX_SCALAR_SESSIONS.pop(key, None)
if 0 <= idx < len(state.flat):
return state.flat[idx]
return state.flat[-1] if state.flat else ""
# The spill registry tracks coordinates that were spilled by each formula cell.
# Key: (doc identity, sheet_name, formula_row, formula_col)
# Identity is the file URL when the workbook has one. Every unsaved book
# reports getURL()==""; those use workbook_lifecycle._lifecycle_key
# (RuntimeUID), never "". LOADED_DOCUMENTS uses the same identity.
# Value: list of (spilled_row, spilled_col) coordinates
SPILL_REGISTRY: dict[tuple[str, str, int, int], list[tuple[int, int]]] = {}
LOADED_DOCUMENTS: set[str] = set()
_SPILL_REGISTRY_LOCK = threading.Lock()
_PENDING_SPILL_LOCK = threading.Lock()
_PENDING_SPILL_TIMERS: list[tuple[str, threading.Timer]] = []
import unohelper
from com.sun.star.util import XModifyListener
# One listener per sheet — SheetModifyDispatcher (Phase 3) or the legacy
# CalcSpillModifyListener when a test constructs it directly.
# First element is the workbook lifecycle id (RuntimeUID). Legacy spill
# listeners still pop a file-URL key from ``disposing``.
SHEET_MODIFY_LISTENERS: dict[tuple[str, str], Any] = {}
@contextmanager
def _undo_lock(doc: Any) -> Iterator[Any]:
"""Temporarily hide or lock undo recording during background spill operations.
If an undo action exists (e.g. user just typed =PY()), enterHiddenUndoContext()
hides the spill mutations under the formula's undo action so the spill does not
create a separate undo step. If the undo stack is empty, um.lock() is used.
"""
um = None
hidden = False
locked = False
try:
raw_doc = doc
try:
from plugin.framework.thread_guard import _unwrap_uno
raw_doc = _unwrap_uno(doc)
except Exception:
pass
if hasattr(raw_doc, "getUndoManager"):
um = raw_doc.getUndoManager()
if um is not None:
try:
if um.isUndoPossible():
um.enterHiddenUndoContext()
hidden = True
elif hasattr(um, "lock"):
um.lock()
locked = True
except Exception:
try:
if hasattr(um, "lock"):
um.lock()
locked = True
except Exception:
pass
except Exception:
um = None
try:
yield um
finally:
if um is not None:
if hidden:
try:
um.leaveUndoContext()
except Exception:
log.debug("leaveUndoContext failed", exc_info=True)
elif locked:
try:
um.unlock()
except Exception:
log.debug("UndoManager.unlock failed", exc_info=True)
class CalcSpillModifyListener(unohelper.Base, XModifyListener):
"""Orphaned-spill cleanup. Walks ``SPILL_REGISTRY`` only.
Geometric repair must not piggyback on this walk (it does not scan
formula cells). The registered listener is ``SheetModifyDispatcher``;
this class stays the spill job. Do not add ``CalcGeometricModifyListener``.
"""
ctx: Any
doc_url: str
sheet_name: str
def __init__(self, ctx: Any, doc_url: str, sheet_name: str) -> None:
self.ctx = ctx
self.doc_url = doc_url
self.sheet_name = sheet_name
def modified(self, aEvent: Any) -> None:
try:
from plugin.framework.thread_guard import on_main_thread
if not on_main_thread():
return
sheet = aEvent.Source
if sheet is None:
return
# Bugfix: orphan cleanup locked undo and saved WriterAgentSpillRegistry
# on the focused workbook. The cells it cleared belong to the sheet
# that fired, which may be a background file.
# How: ``_get_calc_doc`` is ``desktop.getCurrentComponent()``.
# Why: walk to the spreadsheet that owns the sheet. A parent-less
# MagicMock still falls back to the active model so direct tests
# keep their stub.
from plugin.calc.python.sheet_modify import _owning_calc_doc
doc = _owning_calc_doc(sheet)
if doc is None:
doc = _get_calc_doc(self.ctx)
# Bugfix: ``"PY" in formula`` is true for =PYMT and any text that
# merely contains those letters, so replacing =PY() with an
# unrelated formula left the spilled block. is_py_formula_text is
# the =PY( / =PYTHON( (and qualified add-in) check.
from plugin.calc.python.cell_discovery import is_py_formula_text
with _undo_lock(doc):
to_remove = []
for key, value in list(SPILL_REGISTRY.items()):
doc_url, sheet_name, frow, fcol = key
# Bugfix: "" matched every unsaved workbook, so a modify on
# one untitled book cleared the other's spill cells when
# the sheet names matched. Callers pass the file URL or the
# lifecycle id. "" is not an identity.
if self.doc_url and doc_url == self.doc_url and sheet_name == self.sheet_name:
try:
cell = sheet.getCellByPosition(fcol, frow)
formula = cell.getFormula()
if not formula or not is_py_formula_text(str(formula)):
# Clear previously spilled cells
for r, c in value:
if (r, c) != (frow, fcol):
try:
spill_cell = sheet.getCellByPosition(c, r)
spill_cell.clearContents(23)
except Exception:
pass
to_remove.append(key)
except Exception:
log.debug("Failed to inspect formula cell %r", key, exc_info=True)
if to_remove:
for key in to_remove:
SPILL_REGISTRY.pop(key, None)
if doc is not None:
save_spill_registry_for_doc(doc)
except Exception:
log.exception("Error in CalcSpillModifyListener.modified")
def disposing(self, Source: Any) -> None: # noqa: N802, N803 -- UNO signature
SHEET_MODIFY_LISTENERS.pop((self.doc_url, self.sheet_name), None)
def _spill_registry_doc_key(doc: Any) -> str:
"""Key the spill registry and LOADED_DOCUMENTS by RuntimeUID, not URL.
Migrate existing URL keys when the uid is read.
"""
if doc is None:
return ""
uid = ""
try:
if hasattr(doc, "getPropertyValue"):
val = doc.getPropertyValue("RuntimeUID")
if val:
uid = str(val)
except Exception:
uid = ""
url = ""
try:
url_raw = getattr(doc, "getURL", lambda: "")()
url = str(url_raw) if isinstance(url_raw, str) else ""
except Exception:
url = ""
if uid:
key = uid
if url and url != key:
if url in LOADED_DOCUMENTS:
LOADED_DOCUMENTS.discard(url)
LOADED_DOCUMENTS.add(key)
with _SPILL_REGISTRY_LOCK:
for k in list(SPILL_REGISTRY.keys()):
if k[0] == url:
SPILL_REGISTRY[(key, k[1], k[2], k[3])] = SPILL_REGISTRY.pop(k)
return key
if url:
return url
try:
from plugin.calc.python.workbook_lifecycle import _lifecycle_key
return str(_lifecycle_key(doc) or "")
except Exception:
log.debug("spill registry identity failed", exc_info=True)
return ""
def load_spill_registry_for_doc(doc: Any) -> None:
"""Load the document's spill registry from its UserDefinedProperties."""
try:
from plugin.doc.udprops import get_document_property
import json
raw = get_document_property(doc, "WriterAgentSpillRegistry", None)
if not isinstance(raw, str) or not raw.strip():
return
data = json.loads(raw)
doc_key = _spill_registry_doc_key(doc)
# UD JSON stays ``sheet:row,col`` inside this document. The in-memory
# key is per workbook. Do not file those rows under "" (every untitled book).
if not doc_key:
return
for key, value in data.items():
parts = key.split(":")
if len(parts) == 2:
sheet_name, coords = parts
row_col = coords.split(",")
if len(row_col) == 2:
frow, fcol = int(row_col[0]), int(row_col[1])
spill_coords = [(int(r), int(c)) for r, c in value]
SPILL_REGISTRY[(doc_key, sheet_name, frow, fcol)] = spill_coords
except Exception:
log.exception("Failed to load spill registry from document property")
def save_spill_registry_for_doc(doc: Any) -> None:
"""Save the document's spill registry to its UserDefinedProperties."""
try:
from plugin.doc.udprops import set_document_property, get_document_property
import json
doc_key = _spill_registry_doc_key(doc)
if not doc_key:
return
doc_spills = {}
for key, value in SPILL_REGISTRY.items():
k_url, sheet_name, frow, fcol = key
if k_url == doc_key:
doc_spills[f"{sheet_name}:{frow},{fcol}"] = value
new_val = json.dumps(doc_spills)
set_document_property(doc, "WriterAgentSpillRegistry", new_val)
actual = get_document_property(doc, "WriterAgentSpillRegistry", "")
if actual != new_val:
log.warning("Spill registry write back mismatch: expected %r, got %r", new_val, actual)
except Exception:
log.exception("Failed to save spill registry to document property")
def _coerce_spill_value(val: Any, null_dt: datetime.date) -> tuple[Any, dict[str, Any]]:
"""Convert raw grid cell value to Calc-compatible primitive plus temporal metadata.
Returns (calc_val, meta) where meta has 'is_temporal', 'input_category', 'serial'.
"""
if val is None:
return "", {"is_temporal": False, "is_empty": True}
if isinstance(val, bool):
return (1.0 if val else 0.0), {"is_temporal": False, "is_empty": False}
tname = type(val).__name__
if tname in ("NaTType", "NAType"):
return "", {"is_temporal": False, "is_empty": True}
if isinstance(val, (int, float)) and not isinstance(val, bool):
return float(val), {"is_temporal": False, "is_empty": False}
if isinstance(val, datetime.datetime):
dt = val.replace(tzinfo=None) if val.tzinfo is not None else val
days = (dt.date() - null_dt).days
fraction = (dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1_000_000.0) / 86400.0
serial = float(days) + fraction
return serial, {"is_temporal": True, "input_category": "datetime", "serial": serial, "is_empty": False}
if isinstance(val, datetime.date):
serial = float((val - null_dt).days)
return serial, {"is_temporal": True, "input_category": "date", "serial": serial, "is_empty": False}
if isinstance(val, datetime.time):
serial = (val.hour * 3600 + val.minute * 60 + val.second + val.microsecond / 1_000_000.0) / 86400.0
return serial, {"is_temporal": True, "input_category": "time", "serial": serial, "is_empty": False}
if isinstance(val, datetime.timedelta):
serial = val.total_seconds() / 86400.0
return serial, {"is_temporal": True, "input_category": "duration", "serial": serial, "is_empty": False}
# Duck-typed NumPy / Pandas types (np.datetime64, np.timedelta64)
kind = getattr(getattr(val, "dtype", None), "kind", None)
if kind == "M":
to_pydt = getattr(val, "item", None)
if callable(to_pydt):
try:
item = to_pydt()
if isinstance(item, (datetime.datetime, datetime.date)):
return _coerce_spill_value(item, null_dt)
except Exception:
pass
text = str(val)
if text in ("NaT", "NaTType"):
return "", {"is_temporal": False, "is_empty": True}
if kind == "m":
to_pytd = getattr(val, "item", None)
if callable(to_pytd):
try:
item = to_pytd()
if isinstance(item, datetime.timedelta):
return _coerce_spill_value(item, null_dt)
except Exception:
pass
if isinstance(val, str):
stripped = val.strip()
if not stripped:
return "", {"is_temporal": False, "is_empty": True}
if match_iso_duration(stripped):
try:
serial = duration_serial_from_iso(stripped)
return serial, {"is_temporal": True, "input_category": "duration", "serial": serial, "is_empty": False}
except Exception:
pass
cat = match_iso_temporal(stripped)
if cat is not None:
try:
if cat == "date":
d = datetime.date.fromisoformat(stripped)
serial = float((d - null_dt).days)
return serial, {"is_temporal": True, "input_category": "date", "serial": serial, "is_empty": False}
elif cat == "datetime":
dt = datetime.datetime.fromisoformat(stripped.replace(" ", "T"))
days = (dt.date() - null_dt).days
fraction = (dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1_000_000.0) / 86400.0
serial = float(days) + fraction
return serial, {"is_temporal": True, "input_category": "datetime", "serial": serial, "is_empty": False}
elif cat == "time":
t = datetime.time.fromisoformat(stripped)
serial = (t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1_000_000.0) / 86400.0
return serial, {"is_temporal": True, "input_category": "time", "serial": serial, "is_empty": False}
except Exception:
pass
return val, {"is_temporal": False, "is_empty": False}
return to_calc_compatible(val), {"is_temporal": False, "is_empty": False}
def perform_deferred_spill(ctx: Any, doc_url: str, sheet_name: str, formula_row: int, formula_col: int, grid: list[list[Any]], doc: Any | None = None, *, code: str = "", lifecycle_key: str = "") -> None:
"""Clear old spilled cells and write new values deferred (collision check is done synchronously)."""
try:
from plugin.framework.thread_guard import on_main_thread
if not on_main_thread():
return
if doc is None:
return
with _undo_lock(doc):
# Bugfix: ``current_url != doc_url`` is false when both are "".
# Two unsaved books then shared one spill write. The scheduled
# token is the file URL, or the lifecycle id captured when the
# URL was empty. A blank token is the legacy getURL() of an
# untitled book (UNO callers); the registry still uses this
# document's lifecycle id, and a saved book rejects the blank.
live_key = _spill_registry_doc_key(doc)
scheduled = doc_url or ""
if scheduled:
if scheduled != live_key:
return
else:
current_url = ""
try:
current_url = getattr(doc, "getURL", lambda: "")() or ""
except Exception:
current_url = ""
if current_url or not live_key:
return
if lifecycle_key:
try:
from plugin.calc.python.workbook_lifecycle import _lifecycle_key
if _lifecycle_key(doc) != lifecycle_key:
# Closed file reopened with the same URL is a new instance.
return
except Exception:
log.debug("perform_deferred_spill: lifecycle key check failed", exc_info=True)
return
sheet = doc.getSheets().getByName(sheet_name)
if sheet is None:
return
if code:
try:
origin_cell = sheet.getCellByPosition(formula_col, formula_row)
if not is_matching_py_formula(origin_cell.getFormula(), code):
# Formula moved or replaced; do not clear/write stale coordinates.
return
except Exception:
log.debug("perform_deferred_spill: origin formula check failed", exc_info=True)
return
reg_key = (live_key, sheet_name, formula_row, formula_col)
# 1. Clear previously spilled cells
# We overwrite previously spilled cells, not #SPILL! on user edits, because the registry stores coordinates only, not the values we wrote.
previous_spills = SPILL_REGISTRY.get(reg_key, [])
for r, c in previous_spills:
if (r, c) != (formula_row, formula_col):
try:
cell = sheet.getCellByPosition(c, r)
# Clear contents: VALUE, DATETIME, STRING, FORMULA (23)
cell.clearContents(23)
except Exception:
pass
# 2. Determine bounds
num_rows = len(grid)
num_cols = max(len(row) for row in grid) if num_rows > 0 else 0
if num_rows == 0 or num_cols == 0:
SPILL_REGISTRY[reg_key] = []
save_spill_registry_for_doc(doc)
return
# Extract NullDate for temporal day serial conversion
null_dt = datetime.date(1899, 12, 30)
try:
settings = doc.getNumberFormatSettings()
if settings is not None:
nd = settings.getPropertyValue("NullDate")
if nd is not None:
null_dt = datetime.date(getattr(nd, "Year", 1899), getattr(nd, "Month", 12), getattr(nd, "Day", 30))
except Exception:
pass
# 3. Coerce and pad grid values for rectangular setDataArray block write
coerced_grid: list[list[Any]] = []
cell_metas: list[list[dict[str, Any]]] = []
has_any_temporal = False
for row in grid:
coerced_row: list[Any] = []
meta_row: list[dict[str, Any]] = []
for col_idx in range(num_cols):
val = row[col_idx] if col_idx < len(row) else None
calc_val, meta = _coerce_spill_value(val, null_dt)
if meta.get("is_temporal"):
has_any_temporal = True
coerced_row.append(calc_val)
meta_row.append(meta)
coerced_grid.append(coerced_row)
cell_metas.append(meta_row)
# 4. Spill new values using setDataArray to avoid O(N) individual cell writes
# We write with setDataArray, not setFormulaArray, so result strings starting with '=' stay text.
if num_cols > 1:
first_row_range = sheet.getCellRangeByPosition(formula_col + 1, formula_row, formula_col + num_cols - 1, formula_row)
first_row_range.setDataArray((tuple(coerced_grid[0][1:]),))
if num_rows > 1:
remaining_range = sheet.getCellRangeByPosition(formula_col, formula_row + 1, formula_col + num_cols - 1, formula_row + num_rows - 1)
remaining_range.setDataArray(tuple(tuple(row) for row in coerced_grid[1:]))
new_spills = []
for r_offset in range(num_rows):
for c_offset in range(num_cols):
if (r_offset, c_offset) == (0, 0):
continue
new_spills.append((formula_row + r_offset, formula_col + c_offset))
SPILL_REGISTRY[reg_key] = new_spills
save_spill_registry_for_doc(doc)
# 5. Apply NumberFormats for any temporal cells (dates, datetimes, times, durations)
if has_any_temporal:
try:
formats = doc.getNumberFormats()
try:
locale = doc.getPropertyValue("CharLocale")
if not getattr(locale, "Language", None):
import uno
locale = uno.createUnoStruct("com.sun.star.lang.Locale", Language="en", Country="US", Variant="")
except Exception:
import uno
locale = uno.createUnoStruct("com.sun.star.lang.Locale", Language="en", Country="US", Variant="")
# Standard format keys (2=DATE, 4=TIME, 6=DATETIME, 43=DURATION)
standard_keys: dict[str, int] = {}
try:
standard_keys["date"] = int(formats.getStandardFormat(2, locale))
except Exception:
standard_keys["date"] = 36
try:
standard_keys["time"] = int(formats.getStandardFormat(4, locale))
except Exception:
standard_keys["time"] = 40
try:
standard_keys["datetime"] = int(formats.getStandardFormat(6, locale))
except Exception:
standard_keys["datetime"] = 50
try:
dur_key = formats.getFormatIndex(43, locale)
standard_keys["duration"] = int(dur_key) if dur_key != -1 else 43
except Exception:
standard_keys["duration"] = 43
category_cache: dict[int, str | None] = {}
decisions: list[list[Any]] = []
for r_idx in range(num_rows):
row_dec: list[Any] = []
for c_idx in range(num_cols):
meta = cell_metas[r_idx][c_idx]
if meta.get("is_temporal"):
in_cat = meta["input_category"]
serial_val = meta["serial"]
target_c = formula_col + c_idx
target_r = formula_row + r_idx
dest_cat = None
try:
cell = sheet.getCellByPosition(target_c, target_r)
k = int(cell.getPropertyValue("NumberFormat"))
if k not in category_cache:
props = formats.getByKey(k)
category_cache[k] = _format_category_from_type(props.getPropertyValue("Type"))
dest_cat = category_cache[k]
except Exception:
pass
if should_preserve_temporal_format(in_cat, float(serial_val), dest_cat):
row_dec.append(("preserve", None))
else:
apply_k = standard_keys.get(in_cat, standard_keys["date"])
row_dec.append(("apply", int(apply_k)))
elif meta.get("is_empty"):
row_dec.append("empty")
else:
row_dec.append(None)
decisions.append(row_dec)
rects = coalesce_temporal_apply_rects(decisions)
for r0, r1, c0, c1, k in rects:
trange = sheet.getCellRangeByPosition(formula_col + c0, formula_row + r0, formula_col + c1, formula_row + r1)
trange.setPropertyValue("NumberFormat", int(k))
except Exception:
log.exception("Error applying temporal number formats during deferred spill")
except Exception:
log.exception("Error in perform_deferred_spill")
def _result_as_spill_grid(result: list[Any] | tuple[Any, ...]) -> list[list[Any]]:
"""Normalize a 1D list or 2D list-of-lists into a rectangular spill grid."""
if not result:
return []
if any(isinstance(row, (list, tuple)) for row in result):
return [list(row) if isinstance(row, (list, tuple)) else [row] for row in result]
return [[x] for x in result]
def _cell_is_matrix(sheet: Any, cell: Any) -> bool:
"""True when the located formula cell is part of a multi-cell array formula block."""
try:
if hasattr(sheet, "createCursorByRange") and cell is not None:
cursor = sheet.createCursorByRange(cell)
if hasattr(cursor, "collapseToCurrentArray"):
cursor.collapseToCurrentArray()
addr = cursor.getRangeAddress()
return (addr.EndColumn > addr.StartColumn) or (addr.EndRow > addr.StartRow)
except Exception:
log.debug("_cell_is_matrix check failed", exc_info=True)
return False
def _selection_is_multi_cell(target_doc: Any) -> bool:
"""True when the current UI selection spans more than one cell (matrix entry)."""
ctrl = target_doc.getCurrentController() if target_doc is not None else None
if ctrl is None:
return False
selection = ctrl.getSelection()
if selection is None or not hasattr(selection, "getRangeAddress"):
return False
addr = selection.getRangeAddress()
return (addr.EndColumn - addr.StartColumn > 0) or (addr.EndRow - addr.StartRow > 0)
def _prepare_auto_spill(ctx: Any, code: str, grid_to_spill: list[list[Any]], target_doc: Any) -> str | tuple[str, str, int, int] | None:
"""Locate the unique formula origin and check spill collisions (UNO / UI thread).
Returns ``"#SPILL!"`` on collision, ``(doc identity, sheet_name, row, col)`` when the
neighbor write should proceed, or ``None`` when the origin is not unique.
The identity is the file URL, or the lifecycle id when ``getURL()`` is empty.
"""
located = locate_formula_cell_in_doc(ctx, target_doc, code)
if located is None:
return None
doc_key = _spill_registry_doc_key(target_doc)
if not doc_key:
# No file URL and no lifecycle id: refuse the shared "" key.
return None
sheet = located[0]
formula_coord = located[2]
sheet_name = sheet.getName() if hasattr(sheet, "getName") else "Sheet1"
log.debug("Spill: located formula cell at %r on sheet %r for code %r", formula_coord, sheet_name, code)
formula_row, formula_col = formula_coord
if doc_key not in LOADED_DOCUMENTS:
load_spill_registry_for_doc(target_doc)
LOADED_DOCUMENTS.add(doc_key)
try:
from plugin.calc.python.sheet_modify import ensure_sheet_modify_listener
ensure_sheet_modify_listener(ctx, target_doc, sheet)
except Exception:
log.exception("Failed to register modify listener on sheet")
num_rows = len(grid_to_spill)
num_cols = max(len(row) for row in grid_to_spill) if num_rows > 0 else 0
reg_key = (doc_key, sheet_name, formula_row, formula_col)
previous_spills = SPILL_REGISTRY.get(reg_key, [])