diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9ab363..72e3b968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented in this file. This changelog ## [Unreleased] +### Fixed + +- Ensure newly created Excel instances are cleaned up when opening a workbook + fails; retain caller ownership of already-open workbooks and Excel apps. + ### Changed - Switched `.xlsx` / `.xlsm` `light` extraction to the direct OOXML core and diff --git a/benchmark/baselines/issue151-2026-10-03-verbose-windows.json b/benchmark/baselines/issue151-2026-10-03-verbose-windows.json new file mode 100644 index 00000000..7e609e57 --- /dev/null +++ b/benchmark/baselines/issue151-2026-10-03-verbose-windows.json @@ -0,0 +1,471 @@ +{ + "recorded_at": "2026-10-03T12:31:09.222244+00:00", + "platform": "Windows-10-10.0.22631-SP0", + "source": { + "revision": "d28341897fbed2b1ff70c385f51d550365d49130", + "dirty": true + }, + "xlwings": "0.37.4", + "results": [ + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "verbose", + "scenario": "cold", + "scenario_setup_ms": 0.000500120222568512, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 3347.3489000461996, + "com_acquire_ms": 749.5349000673741, + "com_cleanup_ms": 1624.484800035134, + "non_acquire_cleanup_ms": 973.3291999436915, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 3308.18450008519, + "com_acquire_ms": 847.197099821642, + "com_cleanup_ms": 1599.5218001771718, + "non_acquire_cleanup_ms": 861.4656000863761, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 4457.740799989551, + "com_acquire_ms": 1087.0542998891324, + "com_cleanup_ms": 1796.4512000326067, + "non_acquire_cleanup_ms": 1574.2353000678122, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 3191.978899994865, + "com_acquire_ms": 1020.3726999461651, + "com_cleanup_ms": 1289.6652000490576, + "non_acquire_cleanup_ms": 881.9409999996424, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2706.000000005588, + "com_acquire_ms": 800.418599974364, + "com_cleanup_ms": 1196.1946000810713, + "non_acquire_cleanup_ms": 709.3867999501526, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 2540.9721999894828, + "com_acquire_ms": 673.931899946183, + "com_cleanup_ms": 1196.446199901402, + "non_acquire_cleanup_ms": 670.5941001418978, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "verbose", + "scenario": "warm", + "scenario_setup_ms": 497.9292000643909, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 849.1003999952227, + "com_acquire_ms": 154.4955000281334, + "com_cleanup_ms": 11.5062000695616, + "non_acquire_cleanup_ms": 683.0986998975277, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 573.2142999768257, + "com_acquire_ms": 68.90409998595715, + "com_cleanup_ms": 16.45300001837313, + "non_acquire_cleanup_ms": 487.85719997249544, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 557.8694001305848, + "com_acquire_ms": 76.71609986573458, + "com_cleanup_ms": 12.428499991074204, + "non_acquire_cleanup_ms": 468.72480027377605, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 496.0952000692487, + "com_acquire_ms": 68.00309987738729, + "com_cleanup_ms": 8.53420002385974, + "non_acquire_cleanup_ms": 419.55790016800165, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 744.6359000168741, + "com_acquire_ms": 60.46320009045303, + "com_cleanup_ms": 13.170800171792507, + "non_acquire_cleanup_ms": 671.0018997546285, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 793.0935001932085, + "com_acquire_ms": 73.77070002257824, + "com_cleanup_ms": 12.039799941703677, + "non_acquire_cleanup_ms": 707.2830002289265, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "verbose", + "scenario": "already-open", + "scenario_setup_ms": 649.4257999584079, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 690.0422999169677, + "com_acquire_ms": 9.28959995508194, + "com_cleanup_ms": 0.008800067007541656, + "non_acquire_cleanup_ms": 680.7438998948783, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 535.1009001024067, + "com_acquire_ms": 10.882300091907382, + "com_cleanup_ms": 0.005400041118264198, + "non_acquire_cleanup_ms": 524.2131999693811, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 617.6240001805127, + "com_acquire_ms": 6.41679996624589, + "com_cleanup_ms": 0.006500165909528732, + "non_acquire_cleanup_ms": 611.2007000483572, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 513.6461998336017, + "com_acquire_ms": 9.427200071513653, + "com_cleanup_ms": 0.007400056347250938, + "non_acquire_cleanup_ms": 504.2115997057408, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 913.7848000973463, + "com_acquire_ms": 8.922899840399623, + "com_cleanup_ms": 0.005699926987290382, + "non_acquire_cleanup_ms": 904.8562003299594, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "263385a037d108aa06cb28a8fe3371857737ee9621288226ddb6970c99eedb1d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 902.7606998570263, + "com_acquire_ms": 6.8685999140143394, + "com_cleanup_ms": 0.004800036549568176, + "non_acquire_cleanup_ms": 895.8872999064624, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "5d148fa079a4e51fbb7da4c680f78d32f8fb6b4959eaefef22898c24240e1896" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "verbose", + "scenario": "cold", + "scenario_setup_ms": 0.0004998873919248581, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 28833.973500179127, + "com_acquire_ms": 952.3863000795245, + "com_cleanup_ms": 1138.5651999153197, + "non_acquire_cleanup_ms": 26743.022000184283, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 25857.470500050113, + "com_acquire_ms": 714.201700175181, + "com_cleanup_ms": 1524.2839001584798, + "non_acquire_cleanup_ms": 23618.98489971645, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 28767.378899967298, + "com_acquire_ms": 820.279800100252, + "com_cleanup_ms": 1164.949599886313, + "non_acquire_cleanup_ms": 26782.149499980733, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 30012.67970004119, + "com_acquire_ms": 745.4370998311788, + "com_cleanup_ms": 1381.16590003483, + "non_acquire_cleanup_ms": 27886.07670017518, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 28365.62480009161, + "com_acquire_ms": 753.7538001779467, + "com_cleanup_ms": 2349.0160999353975, + "non_acquire_cleanup_ms": 25262.854899978265, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 27933.208599919453, + "com_acquire_ms": 943.1477000471205, + "com_cleanup_ms": 1616.383100161329, + "non_acquire_cleanup_ms": 25373.677799711004, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "verbose", + "scenario": "warm", + "scenario_setup_ms": 666.331399930641, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 42965.190700022504, + "com_acquire_ms": 186.66030000895262, + "com_cleanup_ms": 17.199299996718764, + "non_acquire_cleanup_ms": 42761.33110001683, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 25437.8714999184, + "com_acquire_ms": 131.55860011465847, + "com_cleanup_ms": 12.257300084456801, + "non_acquire_cleanup_ms": 25294.055599719286, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 33959.870300022885, + "com_acquire_ms": 82.39919994957745, + "com_cleanup_ms": 12.3862000182271, + "non_acquire_cleanup_ms": 33865.08490005508, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 22258.647199952975, + "com_acquire_ms": 105.42890010401607, + "com_cleanup_ms": 12.293200008571148, + "non_acquire_cleanup_ms": 22140.925099840388, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 19256.465499987826, + "com_acquire_ms": 72.01760006137192, + "com_cleanup_ms": 14.688299968838692, + "non_acquire_cleanup_ms": 19169.759599957615, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 26370.8446000237, + "com_acquire_ms": 84.07339989207685, + "com_cleanup_ms": 13.30180000513792, + "non_acquire_cleanup_ms": 26273.469400126487, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "verbose", + "scenario": "already-open", + "scenario_setup_ms": 2292.3751999624074, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 34866.021799854934, + "com_acquire_ms": 11.963999830186367, + "com_cleanup_ms": 0.009899958968162537, + "non_acquire_cleanup_ms": 34854.04790006578, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 30257.441599853337, + "com_acquire_ms": 23.0211999733001, + "com_cleanup_ms": 0.0036999117583036423, + "non_acquire_cleanup_ms": 30234.41669996828, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 22648.990500019863, + "com_acquire_ms": 13.776999898254871, + "com_cleanup_ms": 0.003800028935074806, + "non_acquire_cleanup_ms": 22635.209700092673, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 33104.38509983942, + "com_acquire_ms": 9.00100008584559, + "com_cleanup_ms": 0.013400102034211159, + "non_acquire_cleanup_ms": 33095.37069965154, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 29934.653400210664, + "com_acquire_ms": 187.04940006136894, + "com_cleanup_ms": 0.009499955922365189, + "non_acquire_cleanup_ms": 29747.594500193372, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 21140.502099879086, + "com_acquire_ms": 11.307199951261282, + "com_cleanup_ms": 0.002200016751885414, + "non_acquire_cleanup_ms": 21129.192699911073, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "99d70f378a0852fb3f4cbf4a15778ea9b74cdf88bc07be4afb128725567046ff" + } + ], + "output_compatible": true + } + ], + "notes": [ + "Original style-heavy/already-open overlapped a real Excel test; replaced by sequential rerun. Original unfinished large/cold cancelled due to expensive default per-cell color extraction; no timing retained." + ] +} diff --git a/benchmark/baselines/issue151-2026-10-03-windows.json b/benchmark/baselines/issue151-2026-10-03-windows.json new file mode 100644 index 00000000..2c2b81bb --- /dev/null +++ b/benchmark/baselines/issue151-2026-10-03-windows.json @@ -0,0 +1,922 @@ +{ + "recorded_at": "2026-10-03T12:21:34.637362+00:00", + "platform": "Windows-10-10.0.22631-SP0", + "source": { + "revision": "d28341897fbed2b1ff70c385f51d550365d49130", + "dirty": true + }, + "xlwings": "0.37.4", + "results": [ + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "standard", + "scenario": "cold", + "scenario_setup_ms": 0.0014998950064182281, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 3043.046399950981, + "com_acquire_ms": 988.6375998612493, + "com_cleanup_ms": 1734.4438000582159, + "non_acquire_cleanup_ms": 319.96500003151596, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 2645.180999999866, + "com_acquire_ms": 788.7359999585897, + "com_cleanup_ms": 1374.979899963364, + "non_acquire_cleanup_ms": 481.4651000779122, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 2503.0108001083136, + "com_acquire_ms": 692.4970000982285, + "com_cleanup_ms": 1413.9723000116646, + "non_acquire_cleanup_ms": 396.5414999984205, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 2670.426100026816, + "com_acquire_ms": 997.9187999852002, + "com_cleanup_ms": 1577.4477000813931, + "non_acquire_cleanup_ms": 95.05959996022284, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2392.3714000266045, + "com_acquire_ms": 796.8403999693692, + "com_cleanup_ms": 1512.4241998419166, + "non_acquire_cleanup_ms": 83.1068002153188, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 2778.08259986341, + "com_acquire_ms": 741.8715998064727, + "com_cleanup_ms": 1590.8395000733435, + "non_acquire_cleanup_ms": 445.3714999835938, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "standard", + "scenario": "warm", + "scenario_setup_ms": 585.4694999288768, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 266.3921997882426, + "com_acquire_ms": 187.02540011145175, + "com_cleanup_ms": 19.07079992815852, + "non_acquire_cleanup_ms": 60.29599974863231, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 463.8793000485748, + "com_acquire_ms": 66.5138999465853, + "com_cleanup_ms": 16.62210002541542, + "non_acquire_cleanup_ms": 380.7433000765741, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 183.03880002349615, + "com_acquire_ms": 88.1759999319911, + "com_cleanup_ms": 12.542399810627103, + "non_acquire_cleanup_ms": 82.32040028087795, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 112.81700013205409, + "com_acquire_ms": 60.413300059735775, + "com_cleanup_ms": 9.1626001521945, + "non_acquire_cleanup_ms": 43.241099920123816, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 111.25620012171566, + "com_acquire_ms": 55.16760004684329, + "com_cleanup_ms": 13.973499881103635, + "non_acquire_cleanup_ms": 42.11510019376874, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 123.96100000478327, + "com_acquire_ms": 58.47469996660948, + "com_cleanup_ms": 10.933500016108155, + "non_acquire_cleanup_ms": 54.55280002206564, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/small.xlsx", + "input_sha256": "e936689027ed6ba151c99ae74fe8d61d0f4e9cf802954008e18c622d2d8f6cee", + "mode": "standard", + "scenario": "already-open", + "scenario_setup_ms": 634.6122999675572, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 48.931599827483296, + "com_acquire_ms": 8.562800008803606, + "com_cleanup_ms": 0.004499917849898338, + "non_acquire_cleanup_ms": 40.36429990082979, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 218.3995998930186, + "com_acquire_ms": 5.1103997975587845, + "com_cleanup_ms": 0.0018998980522155762, + "non_acquire_cleanup_ms": 213.2873001974076, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 56.69540003873408, + "com_acquire_ms": 4.781600087881088, + "com_cleanup_ms": 0.0018998980522155762, + "non_acquire_cleanup_ms": 51.911900052800775, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 35.905699944123626, + "com_acquire_ms": 4.494599997997284, + "com_cleanup_ms": 0.004200031980872154, + "non_acquire_cleanup_ms": 31.40689991414547, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 36.70089994557202, + "com_acquire_ms": 4.396200180053711, + "com_cleanup_ms": 0.004999805241823196, + "non_acquire_cleanup_ms": 32.299699960276484, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "3fe02ce354d08f7d69f803ed8c8a2eb532f1427e08a75d092de96709aac1aa0e" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 61.16969999857247, + "com_acquire_ms": 4.333300050348043, + "com_cleanup_ms": 0.002499902620911598, + "non_acquire_cleanup_ms": 56.833900045603514, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "12bb40cc05565951c8a26819bd795fb29db8b9dda2ca2278c0e249adceac188f" + } + ], + "output_compatible": false + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "standard", + "scenario": "cold", + "scenario_setup_ms": 0.000400003045797348, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 2027.1421999204904, + "com_acquire_ms": 694.5776001084596, + "com_cleanup_ms": 1168.7129999045283, + "non_acquire_cleanup_ms": 163.85159990750253, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 2417.2269999980927, + "com_acquire_ms": 651.3523000758141, + "com_cleanup_ms": 1230.8804001659155, + "non_acquire_cleanup_ms": 534.994299756363, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 2497.1620000433177, + "com_acquire_ms": 657.3125999420881, + "com_cleanup_ms": 1336.035800166428, + "non_acquire_cleanup_ms": 503.81359993480146, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 2369.109299965203, + "com_acquire_ms": 685.3654000442475, + "com_cleanup_ms": 1448.5391001217067, + "non_acquire_cleanup_ms": 235.20479979924858, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2300.757500110194, + "com_acquire_ms": 679.47500012815, + "com_cleanup_ms": 1454.2354999575764, + "non_acquire_cleanup_ms": 167.0470000244677, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 2385.2844999637455, + "com_acquire_ms": 630.0570999737829, + "com_cleanup_ms": 1268.2197999674827, + "non_acquire_cleanup_ms": 487.0076000224799, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "standard", + "scenario": "warm", + "scenario_setup_ms": 506.04410003870726, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 339.3494999036193, + "com_acquire_ms": 151.82580007240176, + "com_cleanup_ms": 11.750300182029605, + "non_acquire_cleanup_ms": 175.77339964918792, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 536.9945999700576, + "com_acquire_ms": 71.58960006199777, + "com_cleanup_ms": 9.873899864032865, + "non_acquire_cleanup_ms": 455.53110004402697, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 266.4844000246376, + "com_acquire_ms": 105.24990013800561, + "com_cleanup_ms": 11.574400123208761, + "non_acquire_cleanup_ms": 149.6600997634232, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 262.0119999628514, + "com_acquire_ms": 68.66339989937842, + "com_cleanup_ms": 10.640599997714162, + "non_acquire_cleanup_ms": 182.70800006575882, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 252.0799001213163, + "com_acquire_ms": 63.39820008724928, + "com_cleanup_ms": 10.48949989490211, + "non_acquire_cleanup_ms": 178.19220013916492, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 227.0233000162989, + "com_acquire_ms": 67.67289992421865, + "com_cleanup_ms": 10.29099989682436, + "non_acquire_cleanup_ms": 149.05940019525588, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/style-heavy.xlsx", + "input_sha256": "05a77cc146594f2db73cb3aa764ad2625fa798809fce82fbe42900b5cc49529e", + "mode": "standard", + "scenario": "already-open", + "scenario_setup_ms": 622.2759999800473, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 176.0533000342548, + "com_acquire_ms": 8.670100010931492, + "com_cleanup_ms": 0.003800028935074806, + "non_acquire_cleanup_ms": 167.37939999438822, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 328.2023000065237, + "com_acquire_ms": 5.418600048869848, + "com_cleanup_ms": 0.00200001522898674, + "non_acquire_cleanup_ms": 322.7816999424249, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 306.3835999928415, + "com_acquire_ms": 4.956800024956465, + "com_cleanup_ms": 0.001600012183189392, + "non_acquire_cleanup_ms": 301.4251999557018, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 128.16619989462197, + "com_acquire_ms": 6.352700060233474, + "com_cleanup_ms": 0.006300164386630058, + "non_acquire_cleanup_ms": 121.80719967000186, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 187.772799981758, + "com_acquire_ms": 6.581000052392483, + "com_cleanup_ms": 0.009100185707211494, + "non_acquire_cleanup_ms": 181.1826997436583, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 182.39480000920594, + "com_acquire_ms": 7.6235998421907425, + "com_cleanup_ms": 0.002600019797682762, + "non_acquire_cleanup_ms": 174.7686001472175, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "c2e55ebcb8c0f4705068427a831759efd9a3b8eda16593dcbc10689268255373" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "standard", + "scenario": "cold", + "scenario_setup_ms": 0.000400003045797348, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 3269.2966000176966, + "com_acquire_ms": 815.6492998823524, + "com_cleanup_ms": 1741.3618999999017, + "non_acquire_cleanup_ms": 712.2854001354426, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 4060.185299953446, + "com_acquire_ms": 972.55830001086, + "com_cleanup_ms": 1683.9165999554098, + "non_acquire_cleanup_ms": 1403.710399987176, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 2397.6610000245273, + "com_acquire_ms": 861.6646998561919, + "non_acquire_cleanup_ms": 1535.9963001683354, + "com_succeeded": false, + "fallback_reason": "com_pipeline_failed", + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 3741.6133999358863, + "com_acquire_ms": 859.6791999880224, + "com_cleanup_ms": 1753.4303998108953, + "non_acquire_cleanup_ms": 1128.5038001369685, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 3663.6977000162005, + "com_acquire_ms": 893.659400055185, + "com_cleanup_ms": 1659.939100034535, + "non_acquire_cleanup_ms": 1110.0991999264807, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 3294.8594000190496, + "com_acquire_ms": 777.9014001134783, + "com_cleanup_ms": 1474.5863999705762, + "non_acquire_cleanup_ms": 1042.3715999349952, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "standard", + "scenario": "warm", + "scenario_setup_ms": 527.0130999851972, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 735.5877000372857, + "com_acquire_ms": 161.36529995128512, + "com_cleanup_ms": 15.751500148326159, + "non_acquire_cleanup_ms": 558.4708999376744, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1236.3713001832366, + "com_acquire_ms": 108.61580003984272, + "com_cleanup_ms": 14.535299967974424, + "non_acquire_cleanup_ms": 1113.2202001754194, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 704.9420999828726, + "com_acquire_ms": 109.35109993442893, + "com_cleanup_ms": 12.474399991333485, + "non_acquire_cleanup_ms": 583.1166000571102, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 935.7922000344843, + "com_acquire_ms": 126.58359995111823, + "com_cleanup_ms": 12.555700028315187, + "non_acquire_cleanup_ms": 796.6529000550508, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 709.9886999931186, + "com_acquire_ms": 121.94730015471578, + "com_cleanup_ms": 14.48300015181303, + "non_acquire_cleanup_ms": 573.5583996865898, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 773.9196000620723, + "com_acquire_ms": 129.48210001923144, + "com_cleanup_ms": 10.957699967548251, + "non_acquire_cleanup_ms": 633.4798000752926, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "standard", + "scenario": "already-open", + "scenario_setup_ms": 619.409800041467, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 720.0711998157203, + "com_acquire_ms": 8.904000045731664, + "com_cleanup_ms": 0.006299931555986404, + "non_acquire_cleanup_ms": 711.1608998384327, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1006.6925999708474, + "com_acquire_ms": 8.518700022250414, + "com_cleanup_ms": 0.0066999346017837524, + "non_acquire_cleanup_ms": 998.1672000139952, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 624.8624001163989, + "com_acquire_ms": 8.053299970924854, + "com_cleanup_ms": 0.002499902620911598, + "non_acquire_cleanup_ms": 616.8066002428532, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 713.0678999237716, + "com_acquire_ms": 7.768499897792935, + "com_cleanup_ms": 0.005600042641162872, + "non_acquire_cleanup_ms": 705.2937999833375, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 646.3959999382496, + "com_acquire_ms": 10.254899971187115, + "com_cleanup_ms": 0.005899928510189056, + "non_acquire_cleanup_ms": 636.1352000385523, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 792.8359999787062, + "com_acquire_ms": 8.920199936255813, + "com_cleanup_ms": 0.004200031980872154, + "non_acquire_cleanup_ms": 783.9116000104696, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "standard", + "scenario": "cold", + "scenario_setup_ms": 0.00030011869966983795, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 2529.6996999531984, + "com_acquire_ms": 725.5458999425173, + "com_cleanup_ms": 1231.3069000374526, + "non_acquire_cleanup_ms": 572.8468999732286, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 3710.444899974391, + "com_acquire_ms": 562.5189000274986, + "com_cleanup_ms": 1622.442200081423, + "non_acquire_cleanup_ms": 1525.4837998654693, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 4412.403699941933, + "com_acquire_ms": 823.5440000426024, + "com_cleanup_ms": 1643.8974000047892, + "non_acquire_cleanup_ms": 1944.9622998945415, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 3071.4708000887185, + "com_acquire_ms": 937.2277001384646, + "com_cleanup_ms": 1511.7631999310106, + "non_acquire_cleanup_ms": 622.4799000192434, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 3808.2401000428945, + "com_acquire_ms": 892.0090000610799, + "com_cleanup_ms": 1944.6933998260647, + "non_acquire_cleanup_ms": 971.5377001557499, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 3633.43230006285, + "com_acquire_ms": 688.3295001462102, + "com_cleanup_ms": 1529.486499959603, + "non_acquire_cleanup_ms": 1415.616299957037, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "standard", + "scenario": "warm", + "scenario_setup_ms": 565.0184000842273, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 882.5255001429468, + "com_acquire_ms": 195.93849987722933, + "com_cleanup_ms": 14.519699849188328, + "non_acquire_cleanup_ms": 672.0673004165292, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1565.7275998964906, + "com_acquire_ms": 79.6973998658359, + "com_cleanup_ms": 15.501799993216991, + "non_acquire_cleanup_ms": 1470.5284000374377, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1771.5073998551816, + "com_acquire_ms": 112.80210013501346, + "com_cleanup_ms": 15.155700035393238, + "non_acquire_cleanup_ms": 1643.5495996847749, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 1150.6926999427378, + "com_acquire_ms": 122.28580005466938, + "com_cleanup_ms": 17.218299908563495, + "non_acquire_cleanup_ms": 1011.1885999795049, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 932.4343998450786, + "com_acquire_ms": 106.62620002403855, + "com_cleanup_ms": 19.91600007750094, + "non_acquire_cleanup_ms": 805.8921997435391, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 5567.133900010958, + "com_acquire_ms": 96.01070010103285, + "com_cleanup_ms": 15.856300015002489, + "non_acquire_cleanup_ms": 5455.266899894923, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "standard", + "scenario": "already-open", + "scenario_setup_ms": 707.2330999653786, + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 724.7359999455512, + "com_acquire_ms": 10.519200004637241, + "com_cleanup_ms": 0.007899943739175797, + "non_acquire_cleanup_ms": 714.2088999971747, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1308.7219998706132, + "com_acquire_ms": 7.777099963277578, + "com_cleanup_ms": 0.00590016134083271, + "non_acquire_cleanup_ms": 1300.9389997459948, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1271.7098998837173, + "com_acquire_ms": 7.018400123342872, + "com_cleanup_ms": 0.004600035026669502, + "non_acquire_cleanup_ms": 1264.6868997253478, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 1010.2550000883639, + "com_acquire_ms": 7.350399857386947, + "com_cleanup_ms": 0.0072999391704797745, + "non_acquire_cleanup_ms": 1002.8973002918065, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2503.2348001841456, + "com_acquire_ms": 9.145499905571342, + "com_cleanup_ms": 0.0073998235166072845, + "non_acquire_cleanup_ms": 2494.0819004550576, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 1303.7071998696774, + "com_acquire_ms": 14.484599931165576, + "com_cleanup_ms": 0.0018998980522155762, + "non_acquire_cleanup_ms": 1289.2207000404596, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + } + ] +} diff --git a/benchmark/baselines/issue151-compatibility-windows.json b/benchmark/baselines/issue151-compatibility-windows.json new file mode 100644 index 00000000..c6fece0e --- /dev/null +++ b/benchmark/baselines/issue151-compatibility-windows.json @@ -0,0 +1,163 @@ +{ + "file_cells": { + "Sheet": [ + { + "r": 1, + "c": { + "0": "label" + }, + "links": null + }, + { + "r": 2, + "c": { + "1": "2024-01-02 03:04:05" + }, + "links": null + }, + { + "r": 5, + "c": { + "4": -2146826281 + }, + "links": null + }, + { + "r": 6, + "c": { + "5": "True" + }, + "links": null + }, + { + "r": 7, + "c": { + "6": "external" + }, + "links": { + "6": "https://example.test/" + } + }, + { + "r": 8, + "c": { + "7": "internal" + }, + "links": null + } + ] + }, + "com_cells": { + "Sheet": [ + { + "r": 1, + "c": { + "0": "label" + }, + "links": null + }, + { + "r": 2, + "c": { + "1": 45293.12783564815 + }, + "links": null + }, + { + "r": 3, + "c": { + "2": 3 + }, + "links": null + }, + { + "r": 5, + "c": { + "4": -2146826281 + }, + "links": null + }, + { + "r": 6, + "c": { + "5": "True" + }, + "links": null + }, + { + "r": 7, + "c": { + "6": "external" + }, + "links": { + "6": "https://example.test/" + } + }, + { + "r": 8, + "c": { + "7": "internal" + }, + "links": null + } + ] + }, + "unsaved_com_cells": { + "Sheet": [ + { + "r": 1, + "c": { + "0": "unsaved" + }, + "links": null + }, + { + "r": 2, + "c": { + "1": 45293.12783564815 + }, + "links": null + }, + { + "r": 3, + "c": { + "2": 3 + }, + "links": null + }, + { + "r": 5, + "c": { + "4": -2146826281 + }, + "links": null + }, + { + "r": 6, + "c": { + "5": "True" + }, + "links": null + }, + { + "r": 7, + "c": { + "6": "external" + }, + "links": { + "6": "https://example.test/" + } + }, + { + "r": 8, + "c": { + "7": "internal" + }, + "links": null + } + ] + }, + "current_com_succeeded": true, + "candidate_com_succeeded": true, + "full_output_equal": false +} diff --git a/benchmark/baselines/issue151-verbose-no-colors-windows.json b/benchmark/baselines/issue151-verbose-no-colors-windows.json new file mode 100644 index 00000000..c751b974 --- /dev/null +++ b/benchmark/baselines/issue151-verbose-no-colors-windows.json @@ -0,0 +1,484 @@ +{ + "recorded_at": "2026-10-03T12:41:17.056250+00:00", + "platform": "Windows-10-10.0.22631-SP0", + "source": { + "revision": "d28341897fbed2b1ff70c385f51d550365d49130", + "dirty": true + }, + "xlwings": "0.37.4", + "dependencies": { + "openpyxl": "3.1.5", + "numpy": "2.4.6", + "pydantic": "2.13.5" + }, + "results": [ + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "verbose", + "include_colors_map": false, + "scenario": "cold", + "scenario_setup_ms": 0.0002998858690261841, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 4334.244499914348, + "com_acquire_ms": 860.0740998517722, + "com_cleanup_ms": 1882.5163000728935, + "non_acquire_cleanup_ms": 1591.6540999896824, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 4113.330899970606, + "com_acquire_ms": 807.49769997783, + "com_cleanup_ms": 1444.1076999064535, + "non_acquire_cleanup_ms": 1861.7255000863224, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 3989.0495999716222, + "com_acquire_ms": 854.1884000878781, + "com_cleanup_ms": 1220.7175998482853, + "non_acquire_cleanup_ms": 1914.1436000354588, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 3134.499999927357, + "com_acquire_ms": 640.1666000019759, + "com_cleanup_ms": 1291.06180020608, + "non_acquire_cleanup_ms": 1203.2715997193009, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 3666.4812001399696, + "com_acquire_ms": 774.3443001527339, + "com_cleanup_ms": 1291.6693000588566, + "non_acquire_cleanup_ms": 1600.467599928379, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 4255.7365000247955, + "com_acquire_ms": 662.6291000284255, + "com_cleanup_ms": 1683.9715999085456, + "non_acquire_cleanup_ms": 1909.1358000878245, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "verbose", + "include_colors_map": false, + "scenario": "warm", + "scenario_setup_ms": 642.5963998772204, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 1920.539899962023, + "com_acquire_ms": 205.94740007072687, + "com_cleanup_ms": 18.683600006625056, + "non_acquire_cleanup_ms": 1695.908899884671, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 2398.0239001102746, + "com_acquire_ms": 121.59529980272055, + "com_cleanup_ms": 20.633599953725934, + "non_acquire_cleanup_ms": 2255.795000353828, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1733.3339999895543, + "com_acquire_ms": 178.67180006578565, + "com_cleanup_ms": 11.218700092285872, + "non_acquire_cleanup_ms": 1543.4434998314828, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 1447.7263998705894, + "com_acquire_ms": 121.43609998747706, + "com_cleanup_ms": 17.589899944141507, + "non_acquire_cleanup_ms": 1308.7003999389708, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2228.560899849981, + "com_acquire_ms": 137.09970004856586, + "com_cleanup_ms": 20.785999950021505, + "non_acquire_cleanup_ms": 2070.6751998513937, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 1910.1937001105398, + "com_acquire_ms": 147.77620020322502, + "com_cleanup_ms": 13.316500000655651, + "non_acquire_cleanup_ms": 1749.1009999066591, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/large.xlsx", + "input_sha256": "72ff66fa3abde812e9ae3261530b1dd5b88c4dfd5173715b383d896b990f1b97", + "mode": "verbose", + "include_colors_map": false, + "scenario": "already-open", + "scenario_setup_ms": 753.6236001178622, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 1516.9355999678373, + "com_acquire_ms": 12.136299861595035, + "com_cleanup_ms": 0.005899928510189056, + "non_acquire_cleanup_ms": 1504.793400177732, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1510.333700105548, + "com_acquire_ms": 8.592699887230992, + "com_cleanup_ms": 0.002100132405757904, + "non_acquire_cleanup_ms": 1501.7389000859112, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1274.6047999244183, + "com_acquire_ms": 9.727000026032329, + "com_cleanup_ms": 0.004899920895695686, + "non_acquire_cleanup_ms": 1264.8728999774903, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 1251.680799992755, + "com_acquire_ms": 6.099999882280827, + "com_cleanup_ms": 0.005500158295035362, + "non_acquire_cleanup_ms": 1245.5752999521792, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 1416.4011999964714, + "com_acquire_ms": 12.170299887657166, + "com_cleanup_ms": 0.009699957445263863, + "non_acquire_cleanup_ms": 1404.221200151369, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 1355.8426999952644, + "com_acquire_ms": 7.9073000233620405, + "com_cleanup_ms": 0.005200039595365524, + "non_acquire_cleanup_ms": 1347.930199932307, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "dd0dcc08e28a1349e8580e2bc20942663d81b02a2236eaa1883326c1a9103b36" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "verbose", + "include_colors_map": false, + "scenario": "cold", + "scenario_setup_ms": 0.00100000761449337, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 3315.950300078839, + "com_acquire_ms": 959.4270000234246, + "com_cleanup_ms": 1396.43710013479, + "non_acquire_cleanup_ms": 960.0861999206245, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 4084.218699950725, + "com_acquire_ms": 770.6812000833452, + "com_cleanup_ms": 1616.286600008607, + "non_acquire_cleanup_ms": 1697.2508998587728, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 4773.875899845734, + "com_acquire_ms": 787.5431999564171, + "com_cleanup_ms": 1898.5402001999319, + "non_acquire_cleanup_ms": 2087.7924996893853, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 3091.7797998990864, + "com_acquire_ms": 720.2399999368936, + "com_cleanup_ms": 1577.0151000469923, + "non_acquire_cleanup_ms": 794.5246999152005, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 2749.444500077516, + "com_acquire_ms": 760.3386000264436, + "com_cleanup_ms": 1210.7245000079274, + "non_acquire_cleanup_ms": 778.3814000431448, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 3351.4941001776606, + "com_acquire_ms": 636.5547999739647, + "com_cleanup_ms": 1410.3423000779003, + "non_acquire_cleanup_ms": 1304.5970001257956, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "verbose", + "include_colors_map": false, + "scenario": "warm", + "scenario_setup_ms": 577.222399879247, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 936.7317999713123, + "com_acquire_ms": 179.96630002744496, + "com_cleanup_ms": 17.473699990659952, + "non_acquire_cleanup_ms": 739.2917999532074, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1653.4102000296116, + "com_acquire_ms": 114.48460002429783, + "com_cleanup_ms": 17.789199948310852, + "non_acquire_cleanup_ms": 1521.136400057003, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1387.7241997979581, + "com_acquire_ms": 99.51149998232722, + "com_cleanup_ms": 10.621200082823634, + "non_acquire_cleanup_ms": 1277.5914997328073, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 967.03220019117, + "com_acquire_ms": 70.83619991317391, + "com_cleanup_ms": 11.224000016227365, + "non_acquire_cleanup_ms": 884.9720002617687, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 893.3381999377161, + "com_acquire_ms": 90.80670005641878, + "com_cleanup_ms": 17.63460016809404, + "non_acquire_cleanup_ms": 784.8968997132033, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 1679.9870000686496, + "com_acquire_ms": 85.1514998357743, + "com_cleanup_ms": 11.846999870613217, + "non_acquire_cleanup_ms": 1582.9885003622621, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + }, + { + "input": "tasks/issue151-fixtures/many-sheet.xlsx", + "input_sha256": "608cf4728344614879790a083cf641109dfbed26dc9eafc60002fd75ae667ce0", + "mode": "verbose", + "include_colors_map": false, + "scenario": "already-open", + "scenario_setup_ms": 698.2754000928253, + "excel_version": "16.0", + "measurements": [ + { + "repeat": 0, + "backend": "current", + "total_ms": 668.9347999636084, + "com_acquire_ms": 9.630799992009997, + "com_cleanup_ms": 0.003600027412176132, + "non_acquire_cleanup_ms": 659.3003999441862, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 0, + "backend": "com-first", + "total_ms": 1598.7285999581218, + "com_acquire_ms": 6.723900092765689, + "com_cleanup_ms": 0.00390014611184597, + "non_acquire_cleanup_ms": 1592.0007997192442, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "com-first", + "total_ms": 1486.9653000496328, + "com_acquire_ms": 7.6321999076753855, + "com_cleanup_ms": 0.005899928510189056, + "non_acquire_cleanup_ms": 1479.3272002134472, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 1, + "backend": "current", + "total_ms": 785.6038000900298, + "com_acquire_ms": 7.648099912330508, + "com_cleanup_ms": 0.005699926987290382, + "non_acquire_cleanup_ms": 777.950000250712, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "current", + "total_ms": 737.872299971059, + "com_acquire_ms": 8.559299865737557, + "com_cleanup_ms": 0.009800074622035027, + "non_acquire_cleanup_ms": 729.3032000306994, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + }, + { + "repeat": 2, + "backend": "com-first", + "total_ms": 1483.2190000452101, + "com_acquire_ms": 10.609199991449714, + "com_cleanup_ms": 0.00400003045797348, + "non_acquire_cleanup_ms": 1472.6058000233024, + "com_succeeded": true, + "fallback_reason": null, + "output_sha256": "25bd3c579cdb9ae0ffdb303ad712855c5634cee89660f75dee49a96934943f8d" + } + ], + "output_compatible": true + } + ] +} diff --git a/benchmark/issue151-results.md b/benchmark/issue151-results.md new file mode 100644 index 00000000..991ec56f --- /dev/null +++ b/benchmark/issue151-results.md @@ -0,0 +1,169 @@ +# Issue #151: COM-first extraction evaluation + +## Outcome + +Retain public file-first `standard` / `verbose` extraction. The bulk COM +candidate does not meet both performance and output compatibility requirements. +`ComBackend.extract_cells()` and the COM-first runner remain internal experiments. +ADR-0014 records the rationale; no public backend selector is added. + +## Method and reproduction + +Windows 11 (build 22631), Python 3.11.13, Excel 16.0, xlwings 0.37.4, +openpyxl 3.1.5, NumPy 2.4.6 and Pydantic 2.13.5. Base revision: +`d28341897fbed2b1ff70c385f51d550365d49130` plus this working-tree change. +SciPy was absent during the original timings; both runners used the existing +Python fallback. Installing optional test dependencies later does not change +the recorded run environment. + +Generate fixtures in a new directory and run sequentially with no other Excel +instances open. Do not run the real COM tests concurrently with measurements. + +```powershell +rtk uv run python -m benchmark.performance --generate-fixtures tasks/issue151-fixtures +rtk uv run python -m benchmark.issue151_com_first --input tasks/issue151-fixtures/small.xlsx tasks/issue151-fixtures/style-heavy.xlsx tasks/issue151-fixtures/large.xlsx tasks/issue151-fixtures/many-sheet.xlsx --output benchmark/baselines/issue151-2026-10-03-windows.json --repeats 3 +rtk uv run python -m benchmark.issue151_com_first --input tasks/issue151-fixtures/small.xlsx tasks/issue151-fixtures/style-heavy.xlsx --output benchmark/baselines/issue151-2026-10-03-verbose-windows.json --repeats 3 --modes verbose +rtk uv run python -m benchmark.issue151_com_first --input tasks/issue151-fixtures/large.xlsx tasks/issue151-fixtures/many-sheet.xlsx --output benchmark/baselines/issue151-verbose-no-colors-windows.json --repeats 3 --modes verbose --no-colors +rtk uv run python -m benchmark.issue151_compatibility --output benchmark/baselines/issue151-compatibility-windows.json +rtk uv run python -m benchmark.issue151_report +``` + +Fixtures: small = 20 x 8 with a formula, merge and chart; medium/style-heavy = +300 x 20; large = 2,000 x 30; many-sheet = 24 sheets of 30 x 10. ZIP hashes +are recorded for reproduction. Default include flags are preserved for standard and the small/medium verbose runs. Large/many-sheet verbose runs explicitly disable colors to isolate the cell architecture; they are reported separately. +The candidate keeps the shared openpyxl table/merged/formula stages and existing +rich COM stages; it replaces cell reading with bounded `Value2` batches and +bulk `ISERROR` masks. It does not use per-cell COM value access. +The review correction seeds saved print areas before rich COM extraction, matching +the public pipeline's preference for file metadata. The retained timings predate +this correction and have not been remeasured; raw baseline JSON remains unchanged. + +Cold starts/quits a new app for each extraction. Warm deliberately opens/closes +the workbook in a controlled, running app for both runners. Already-open reuses +the open workbook without closing it. This warm injection is a benchmark +condition, not a new production app-reuse policy. Raw JSON separates acquisition, +cleanup, total time and the remaining extraction/model work. Setup of warm/open +scenarios is outside timed extraction. Excel app IDs are checked after cleanup. +Runner order alternates over three repeats. Timing excludes output hashing. +Medians and output equality below include only successful COM runs; failed runs +remain in raw JSON. If either runner has no successful sample, output equality +is unknown. This avoids mistaking fallback output for COM-reader compatibility. +These are local observations, not statistical confidence intervals or universal +claims about Excel performance. Python imports are not separately cold-started. + +## Standard results + +| Fixture | Scenario | Current ms | Candidate ms | Output equal | Failed COM | +| --- | --- | ---: | ---: | --- | ---: | +| small | cold | 2670.4 | 2645.2 | False | 0 | +| small | warm | 112.8 | 183.0 | False | 0 | +| small | already-open | 36.7 | 61.2 | False | 0 | +| style-heavy | cold | 2300.8 | 2417.2 | True | 0 | +| style-heavy | warm | 262.0 | 266.5 | True | 0 | +| style-heavy | already-open | 176.1 | 306.4 | True | 0 | +| large | cold | 3663.7 | 3677.5 | True | 1 | +| large | warm | 735.6 | 773.9 | True | 0 | +| large | already-open | 713.1 | 792.8 | True | 0 | +| many-sheet | cold | 3071.5 | 3710.4 | True | 0 | +| many-sheet | warm | 932.4 | 1771.5 | True | 0 | +| many-sheet | already-open | 1010.3 | 1303.7 | True | 0 | + +One large/cold candidate run encountered COM RPC failure and returned the clean +file fallback. Its ~2,397.7 ms total is not evidence of faster successful extraction. +Excluding that failure removes the apparent cold/large advantage. + +## Verbose default results + +| Fixture | Scenario | Current ms | Candidate ms | Output equal | Failed COM | +| --- | --- | ---: | ---: | --- | ---: | +| small | cold | 3192.0 | 3308.2 | False | 0 | +| small | warm | 744.6 | 573.2 | False | 0 | +| small | already-open | 690.0 | 617.6 | False | 0 | +| style-heavy | cold | 28834.0 | 27933.2 | True | 0 | +| style-heavy | warm | 22258.6 | 26370.8 | True | 0 | +| style-heavy | already-open | 33104.4 | 22649.0 | True | 0 | + +Small formula results differ despite some faster warm samples. Medium timings +are dominated by existing per-cell COM color extraction and fluctuate materially. +The original medium/already-open scenario overlapped an Excel test and was +discarded; the table uses its isolated rerun. Large default verbose was stopped +without retaining an unfinished timing because color extraction dominated. +Large/many-sheet verbose below disables colors explicitly; those timings are +not default verbose timings. A first isolation attempt overlapped another +Excel test and failed with RPC errors; that attempt was discarded and rerun +with no concurrent tests. + +## Verbose with colors explicitly disabled + +| Fixture | Scenario | Current ms | Candidate ms | Output equal | Failed COM | +| --- | --- | ---: | ---: | --- | ---: | +| large | cold | 3666.5 | 4113.3 | True | 0 | +| large | warm | 1920.5 | 1910.2 | True | 0 | +| large | already-open | 1416.4 | 1355.8 | True | 0 | +| many-sheet | cold | 3091.8 | 4084.2 | True | 0 | +| many-sheet | warm | 936.7 | 1653.4 | True | 0 | +| many-sheet | already-open | 737.9 | 1487.0 | True | 0 | + +## Compatibility and reliability + +The generated compatibility probe reproduces these differences: + +| Case | Saved-file reader | Bulk COM reader | +| --- | --- | --- | +| Date/time | `2024-01-02 03:04:05` | `45293.12783564815` | +| Formula with absent cache | omitted | recalculated `3` | +| Unsaved edit | saved `label` | live `unsaved` | +| External hyperlink | retained | retained | +| Internal location-only link | omitted | omitted | +| Excel error | omitted | omitted | +| Integer matching COM error code | retained | retained | +| Boolean | string `True` | string `True` | + +The small fixture differs because Excel supplies a recalculated formula value. +The compatibility fixture also confirms full output inequality while both COM +pipelines succeed. Dates require style-aware interpretation, including workbook +date systems, before Value2 can preserve the current contract. Selecting live +values would require a separate human-approved public contract decision. + +Failure-injection tests cover startup, cells, rich extraction and final model +construction. They verify that fallback starts after COM ownership exits and +discards partial artifacts. Lifecycle tests cover open failure, extraction +failure, close failure, quit failure/owned kill, and existing borrowed workbook +preservation. A new app formerly leaked if `books.open` raised before `try`; +that defect is fixed independently of the experiment. + +## Validation + +- Non-COM suite with `mcp` and `fast` extras: 1,093 passed, 2 skipped, + 12 deselected before the additional batch-boundary and real-COM tests. +- Focused unit tests after batch, cleanup and review fixes: 24 passed. +- PR review regressions and related backend/workbook unit tests: 74 passed. +- Ruff and strict mypy (89 source files) pass. +- Real Excel compatibility test: 1 passed with both pipelines succeeding. + Pytest emitted Windows RPC exception diagnostics (`0x800706be`) even on + the sequential rerun; exit code was 0 and no Excel app remained. This is + output-compatibility evidence, not a clean runtime-stability result. +- The PR review rerun of the real Excel test also passed, including external + hyperlink type lookup, and left no Excel app. RPC diagnostics included both + `0x800706be` and `0x800706ba`; the same runtime-stability limitation applies. +- Raw baseline JSON and the compatibility probe are retained under + `benchmark/baselines/`; timing tables can be regenerated with the report runner. + +## Reconsideration gate + +Retain file-first because the candidate adds COM cost on many warm/many-sheet +paths and changes saved-value semantics. Table detection remains file-based: +the experiment provides no evidence supporting a COM heuristic rewrite. +Future candidates must preserve date/cache/unsaved semantics, verify owned and +borrowed cleanup, report successful and fallback timings separately, and show +repeatable gains across modes and Excel startup conditions before adoption. + +## ADR workflow checks + +Suggester: recommended (performance strategy retained); drafter: ADR-0014. +Linter: no findings (valid status, required sections, balanced consequences, +evidence triad and no supersession). Reviewer: ready, no design findings. +Reconciler: no policy drift or evidence gaps against ADR-0002/0011/0013, +specs, source and tests. Indexer: README, index.yaml and decision-map synchronized; +14 source paths and domain memberships verified. Remaining limits are local +timing variability and incomplete default large-verbose timings. diff --git a/benchmark/issue151_com_first.py b/benchmark/issue151_com_first.py new file mode 100644 index 00000000..22c1e687 --- /dev/null +++ b/benchmark/issue151_com_first.py @@ -0,0 +1,316 @@ +"""Internal COM-first experiment; public extraction remains file-first.""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import replace +from datetime import UTC, datetime +import hashlib +from importlib.metadata import version +import json +import os +from pathlib import Path +import platform +from time import perf_counter +from typing import Any +from unittest.mock import patch + +from exstruct.core import pipeline +from exstruct.core.backends.com_backend import ComBackend +from exstruct.core.backends.openpyxl_backend import OpenpyxlBackend +from exstruct.core.modeling import WorkbookRawData, build_workbook_data +from exstruct.core.openpyxl_session import OpenpyxlExtractionSession +from exstruct.errors import FallbackReason + + +def com_first(inputs: pipeline.ExtractionInputs) -> pipeline.PipelineResult: + """Attempt COM before file work; discard partial artifacts on failure.""" + if inputs.mode not in {"standard", "verbose"}: + raise ValueError("Experiment requires standard or verbose") + if os.getenv("SKIP_COM_TESTS"): + return pipeline._run_openpyxl_pipeline(inputs) + attempted = False + try: + with pipeline.xlwings_workbook(inputs.file_path) as book: + attempted = True + # Tables/merged ranges and file formula semantics stay file-based. + # Their shared session loads only if the requested stage needs it. + with OpenpyxlExtractionSession(inputs.file_path) as session: + file_backend = OpenpyxlBackend(inputs.file_path, session=session) + artifacts = pipeline.ExtractionArtifacts(openpyxl_session=session) + artifacts.cell_data = ComBackend(book).extract_cells( + include_links=inputs.include_cell_links + ) + if inputs.include_merged_cells: + artifacts.merged_cell_data = file_backend.extract_merged_cells() + if inputs.include_print_areas: + artifacts.print_area_data = file_backend.extract_print_areas() + if inputs.include_formulas_map and not inputs.use_com_for_formulas: + artifacts.formulas_map_data = file_backend.extract_formulas_map() + pipeline.run_com_pipeline( + pipeline.build_com_pipeline(inputs), inputs, artifacts, book + ) + sheets = pipeline.collect_sheet_raw_data( + cell_data=artifacts.cell_data, + shape_data=artifacts.shape_data, + chart_data=artifacts.chart_data, + merged_cell_data=artifacts.merged_cell_data, + workbook=book, + mode=inputs.mode, + include_merged_values_in_rows=inputs.include_merged_values_in_rows, + print_area_data=artifacts.print_area_data, + auto_page_break_data=artifacts.auto_page_break_data, + formulas_map_data=artifacts.formulas_map_data, + colors_map_data=artifacts.colors_map_data, + openpyxl_session=session, + ) + return pipeline.PipelineResult( + workbook=build_workbook_data( + WorkbookRawData(book_name=inputs.file_path.name, sheets=sheets) + ), + artifacts=artifacts, + state=pipeline.PipelineState( + com_attempted=True, com_succeeded=True + ), + ) + except Exception as exc: + pipeline.logger.warning("COM-first experiment failed: %r", exc) + # Resource scopes have ended before the clean file-only fallback begins. + with patch.dict(os.environ, {"SKIP_COM_TESTS": "1"}): + result = pipeline._run_openpyxl_pipeline(inputs) + result.state.com_attempted = attempted + result.state.fallback_reason = ( + FallbackReason.COM_PIPELINE_FAILED + if attempted + else FallbackReason.COM_UNAVAILABLE + ) + return result + + +def inputs_for(path: Path, mode: str) -> pipeline.ExtractionInputs: + inputs = pipeline.resolve_extraction_inputs( + path.resolve(), + mode="standard", + include_cell_links=None, + include_print_areas=None, + include_auto_page_breaks=False, + include_colors_map=None, + include_default_background=False, + ignore_colors=None, + include_formulas_map=None, + include_merged_cells=None, + include_merged_values_in_rows=True, + ) + if mode == "verbose": + inputs = replace( + inputs, + mode="verbose", + include_cell_links=True, + include_formulas_map=True, + include_colors_map=True, + ) + return inputs + + +def digest(result: pipeline.PipelineResult) -> str: + encoded = json.dumps(result.workbook.model_dump(mode="json"), sort_keys=True) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def output_compatible(records: list[dict[str, Any]]) -> bool | None: + """Compare successful outputs only; missing success on either side is unknown.""" + hashes = { + backend: { + record["output_sha256"] + for record in records + if record["backend"] == backend and record["com_succeeded"] + } + for backend in ("current", "com-first") + } + if not all(hashes.values()): + return None + return len(hashes["current"] | hashes["com-first"]) == 1 + + +def _quit_owned_app(owner: Any) -> None: # noqa: ANN401 - benchmark COM duck type + try: + owner.quit() + except Exception: + owner.kill() + + +def measure( + path: Path, mode: str, scenario: str, repeats: int, *, include_colors: bool = True +) -> dict[str, Any]: + import xlwings as xw + + inputs = inputs_for(path, mode) + if not include_colors: + inputs = replace(inputs, include_colors_map=False) + owner = None + book = None + before = sorted(xw.apps.keys()) + if before: + raise RuntimeError( + "Close existing Excel instances before controlled measurements" + ) + setup_started = perf_counter() + try: + if scenario != "cold": + owner = xw.App(visible=False, add_book=False) + owner.display_alerts = False + if scenario == "already-open": + book = owner.books.open(str(inputs.file_path)) + setup_ms = (perf_counter() - setup_started) * 1000 + excel_version = str(owner.version) if owner is not None else None + original = pipeline.xlwings_workbook + records = [] + for repeat in range(repeats): + # Alternate order to reduce systematic cache/order bias. + for backend in ( + ("current", "com-first") + if repeat % 2 == 0 + else ("com-first", "current") + ): + phases: dict[str, float] = {} + + @contextmanager + def timed_workbook( + file_path: Path, phases: dict[str, float] = phases + ) -> Iterator[Any]: + start = perf_counter() + borrowed = scenario == "already-open" + if owner is None or borrowed: + with original(file_path) as workbook: + nonlocal excel_version + excel_version = str(workbook.app.version) + phases["com_acquire_ms"] = (perf_counter() - start) * 1000 + try: + yield workbook + finally: + cleanup_started = perf_counter() + phases["com_cleanup_ms"] = ( + perf_counter() - cleanup_started + ) * 1000 + else: + workbook = owner.books.open(str(file_path)) + phases["com_acquire_ms"] = (perf_counter() - start) * 1000 + try: + yield workbook + finally: + start = perf_counter() + workbook.close() + phases["com_cleanup_ms"] = (perf_counter() - start) * 1000 + + started = perf_counter() + with patch.object(pipeline, "xlwings_workbook", timed_workbook): + result = ( + pipeline.run_extraction_pipeline + if backend == "current" + else com_first + )(inputs) + total = (perf_counter() - started) * 1000 + records.append( + { + "repeat": repeat, + "backend": backend, + "total_ms": total, + **phases, + "non_acquire_cleanup_ms": total - sum(phases.values()), + "com_succeeded": result.state.com_succeeded, + "fallback_reason": result.state.fallback_reason, + "output_sha256": digest(result), + } + ) + return { + "input": path.as_posix(), + "input_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "mode": mode, + "include_colors_map": inputs.include_colors_map, + "scenario": scenario, + "scenario_setup_ms": setup_ms, + "excel_version": excel_version, + "measurements": records, + "output_compatible": output_compatible(records), + } + finally: + try: + if book is not None: + book.close() + finally: + if owner is not None: + _quit_owned_app(owner) + after = sorted(xw.apps.keys()) + if after != before: + raise RuntimeError( + f"Excel process cleanup mismatch: before={before}, after={after}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, nargs="+", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument( + "--no-colors", + action="store_true", + help="Disable the existing per-cell color stage to isolate large-workbook cell architecture", + ) + parser.add_argument( + "--modes", nargs="+", choices=["standard", "verbose"], default=["standard"] + ) + parser.add_argument( + "--scenarios", + nargs="+", + choices=["cold", "warm", "already-open"], + default=["cold", "warm", "already-open"], + ) + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + if any(path.resolve() == args.output.resolve() for path in args.input): + parser.error("output must differ from inputs") + import xlwings as xw + + from benchmark.performance import source_metadata + + results = [] + for path in args.input: + for mode in args.modes: + for scenario in args.scenarios: + print(f"Measuring {path.name} {mode} {scenario}", flush=True) + results.append( + measure( + path, + mode, + scenario, + args.repeats, + include_colors=not args.no_colors, + ) + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps( + { + "recorded_at": datetime.now(UTC).isoformat(), + "platform": platform.platform(), + "source": source_metadata(10), + "xlwings": xw.__version__, + "dependencies": { + name: version(name) + for name in ("openpyxl", "numpy", "pydantic") + }, + "results": results, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/benchmark/issue151_compatibility.py b/benchmark/issue151_compatibility.py new file mode 100644 index 00000000..9408161f --- /dev/null +++ b/benchmark/issue151_compatibility.py @@ -0,0 +1,74 @@ +"""Reproduce Value2 compatibility limits without modifying source workbooks.""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from benchmark.issue151_com_first import com_first, inputs_for +from exstruct.core import pipeline +from exstruct.core.backends.com_backend import ComBackend +from exstruct.core.backends.openpyxl_backend import OpenpyxlBackend +from exstruct.core.workbook import xlwings_workbook + + +def probe() -> dict[str, object]: + from openpyxl import Workbook + + with TemporaryDirectory(prefix="exstruct-issue151-") as directory: + path = Path(directory) / "compatibility.xlsx" + book = Workbook() + sheet = book.active + sheet["A1"] = "label" + sheet["B2"] = datetime(2024, 1, 2, 3, 4, 5) + sheet["C3"] = "=1+2" # No stored formula cache in generated OOXML. + sheet["D4"] = "#DIV/0!" + sheet["E5"] = -2146826281 # Legitimate number, equal to a COM error code. + sheet["F6"] = True + sheet["G7"] = "external" + sheet["G7"].hyperlink = "https://example.test/" + sheet["H8"] = "internal" + from openpyxl.worksheet.hyperlink import Hyperlink + + sheet["H8"].hyperlink = Hyperlink(ref="H8", location="Sheet!A1") + book.save(path) + book.close() + saved = OpenpyxlBackend(path).extract_cells(include_links=True) + with xlwings_workbook(path) as live: + bulk = ComBackend(live).extract_cells(include_links=True) + live.sheets[0].range("A1").value = "unsaved" + unsaved = ComBackend(live).extract_cells(include_links=True) + inputs = inputs_for(path, "standard") + current = pipeline.run_extraction_pipeline(inputs) + candidate = com_first(inputs) + return { + "file_cells": { + name: [row.model_dump() for row in rows] for name, rows in saved.items() + }, + "com_cells": { + name: [row.model_dump() for row in rows] for name, rows in bulk.items() + }, + "unsaved_com_cells": { + name: [row.model_dump() for row in rows] + for name, rows in unsaved.items() + }, + "current_com_succeeded": current.state.com_succeeded, + "candidate_com_succeeded": candidate.state.com_succeeded, + "full_output_equal": current.workbook == candidate.workbook, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = probe() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/benchmark/issue151_report.py b/benchmark/issue151_report.py new file mode 100644 index 00000000..853ebb98 --- /dev/null +++ b/benchmark/issue151_report.py @@ -0,0 +1,39 @@ +"""Render recorded COM-first timings, excluding failed COM samples.""" + +import json +from pathlib import Path +import statistics + +from benchmark.issue151_com_first import output_compatible + + +def table(path: Path) -> str: + data = json.loads(path.read_text(encoding="utf-8")) + lines = [ + "| Fixture | Scenario | Current ms | Candidate ms | Output equal | Failed COM |", + "| --- | --- | ---: | ---: | --- | ---: |", + ] + for result in data["results"]: + records = result["measurements"] + medians = [] + for backend in ("current", "com-first"): + values = [ + r["total_ms"] + for r in records + if r["backend"] == backend and r["com_succeeded"] + ] + medians.append(f"{statistics.median(values):.1f}" if values else "n/a") + compatible = output_compatible(records) + lines.append( + f"| {Path(result['input']).stem} | {result['scenario']} | {' | '.join(medians)} | {compatible if compatible is not None else 'unknown'} | {sum(not r['com_succeeded'] for r in records)} |" + ) + return "\n".join(lines) + + +if __name__ == "__main__": + for mode, filename in ( + ("standard", "issue151-2026-10-03-windows.json"), + ("verbose", "issue151-2026-10-03-verbose-windows.json"), + ("verbose (colors disabled)", "issue151-verbose-no-colors-windows.json"), + ): + print(f"### {mode}\n\n{table(Path('benchmark/baselines') / filename)}\n") diff --git a/dev-docs/adr/ADR-0014-retain-file-first-com-extraction.md b/dev-docs/adr/ADR-0014-retain-file-first-com-extraction.md new file mode 100644 index 00000000..ed8a3fc6 --- /dev/null +++ b/dev-docs/adr/ADR-0014-retain-file-first-com-extraction.md @@ -0,0 +1,65 @@ +# ADR-0014: Retain File-First COM Extraction + +## 状態 + +`proposed` + +## 背景 + +Issue #151 evaluates COM-first `standard` / `verbose` extraction after the +file-reading optimizations in #142. Bulk `Value2` avoids individual cell +value calls, but may still incur more COM overhead than the shared file reader. +Excel exposes live/recalculated values rather than saved cached values. +This performance experiment does not propose a new public mode or output contract. + +## 決定 + +- Retain the current file-first public `standard` / `verbose` pipeline. + The measured candidate does not satisfy the issue's combined performance + and compatibility conditions. Keep `ComBackend.extract_cells()` as an + internal prototype used only by the benchmark experiment. +- Retain shared openpyxl table detection and merged/formula metadata in the + experiment. Do not replace table heuristics with per-cell COM scanning. +- Reconsider adoption only after preserving date interpretation, saved formula + caches and saved-file values when a workbook has unsaved edits, and after + reproducible improvement across cold, warm and many-sheet cases. +- Repair owned Excel cleanup independently: opening failure must still quit + the newly created app. Failed quit may kill only that owned app. Borrowed + workbooks/apps remain caller-owned. Preserve ADR-0002's fallback contract. + +## 影響 + +- Existing cell semantics, fallback reasons and public entrypoints remain + stable. Avoiding a default change prevents date serials and recalculated + formulas from silently changing consumer output. +- File-first continues to pay preprocessing costs before COM. Some potential + optimization remains unexplored. +- The prototype and benchmark add maintenance cost but preserve evidence for + revisiting the question. Process-global patches in the experiment are for + sequential benchmarks, not concurrent production. +- Local Windows results do not cover every Excel build or every possible + COM-first design. COM failures must be separated from success-path timings. + +## 根拠 + +- Tests: `tests/backends/test_com_bulk_cells.py`, + `tests/benchmark/test_issue151_com_first.py`, + `tests/core/test_workbook_utils.py`. +- Code: `src/exstruct/core/backends/com_backend.py`, + `src/exstruct/core/workbook.py`, `src/exstruct/core/pipeline.py`, + `benchmark/issue151_com_first.py`, `benchmark/issue151_compatibility.py`. +- Related specs: `dev-docs/specs/excel-extraction.md`, + `dev-docs/specs/extraction-performance.md`, `benchmark/issue151-results.md`. +- Related decisions: ADR-0002 rich fallback, ADR-0011 workbook reuse, + ADR-0013 light-only OOXML selection. +- Classification: `recommended`; evaluate and retain the performance strategy + without changing public semantics. Primary domain: `performance`; + other domains: `backend`, `extraction`, `compatibility`. + +## Supersedes + +- None + +## Superseded by + +- None diff --git a/dev-docs/adr/README.md b/dev-docs/adr/README.md index 11d91c74..99d01c3a 100644 --- a/dev-docs/adr/README.md +++ b/dev-docs/adr/README.md @@ -48,3 +48,4 @@ ADRs record what was decided, under which constraints, and which trade-offs were | `ADR-0011` | Extraction-Scoped Workbook Reuse and Direct Cell Reading | `proposed` | `extraction` | | `ADR-0012` | Optional SciPy Border Clustering | `proposed` | `performance` | | `ADR-0013` | Default OOXML Backend for Light Extraction | `proposed` | `backend` | +| `ADR-0014` | Retain File-First COM Extraction | `proposed` | `performance` | diff --git a/dev-docs/adr/decision-map.md b/dev-docs/adr/decision-map.md index 81b1a82e..029dfb6c 100644 --- a/dev-docs/adr/decision-map.md +++ b/dev-docs/adr/decision-map.md @@ -4,6 +4,8 @@ This document is a human-readable map for navigating ADRs by domain. ## extraction +- `ADR-0014` Retain File-First COM Extraction (`proposed`) + - `ADR-0001` Extraction Mode Responsibility Boundaries (`superseded`) - `ADR-0002` Rich Backend Fallback Policy (`accepted`) - `ADR-0008` Extraction CLI Runtime Capability Validation (`accepted`) @@ -19,6 +21,8 @@ This document is a human-readable map for navigating ADRs by domain. ## backend +- `ADR-0014` Retain File-First COM Extraction (`proposed`) + - `ADR-0002` Rich Backend Fallback Policy (`accepted`) - `ADR-0004` Patch Backend Selection Policy (`accepted`) - `ADR-0010` Light Mode as the Pure-Python Rich OOXML Baseline (`accepted`) @@ -41,6 +45,8 @@ This document is a human-readable map for navigating ADRs by domain. ## compatibility +- `ADR-0014` Retain File-First COM Extraction (`proposed`) + - `ADR-0003` Output Serialization Omission Policy (`accepted`) - `ADR-0004` Patch Backend Selection Policy (`accepted`) - `ADR-0007` Editing CLI as Public Operational Interface (`accepted`) @@ -52,6 +58,8 @@ This document is a human-readable map for navigating ADRs by domain. ## performance +- `ADR-0014` Retain File-First COM Extraction (`proposed`) + - `ADR-0011` Extraction-Scoped Workbook Reuse and Direct Cell Reading (`proposed`) - `ADR-0012` Optional SciPy Border Clustering (`proposed`) - `ADR-0013` Default OOXML Backend for Light Extraction (`proposed`) diff --git a/dev-docs/adr/index.yaml b/dev-docs/adr/index.yaml index 10affed4..ccf22825 100644 --- a/dev-docs/adr/index.yaml +++ b/dev-docs/adr/index.yaml @@ -212,3 +212,19 @@ adrs: - docs/cli.md - README.md - README.ja.md + - id: ADR-0014 + title: Retain File-First COM Extraction + status: proposed + path: dev-docs/adr/ADR-0014-retain-file-first-com-extraction.md + primary_domain: performance + domains: + - performance + - backend + - extraction + - compatibility + supersedes: [] + superseded_by: [] + related_specs: + - dev-docs/specs/excel-extraction.md + - dev-docs/specs/extraction-performance.md + - benchmark/issue151-results.md diff --git a/dev-docs/specs/excel-extraction.md b/dev-docs/specs/excel-extraction.md index 70a6e52e..8bc2859c 100644 --- a/dev-docs/specs/excel-extraction.md +++ b/dev-docs/specs/excel-extraction.md @@ -25,6 +25,13 @@ This document summarizes the current specification for Excel extraction processi ## Workbook Resource Lifetime +- `standard` / `verbose` retain file-first extraction after the Issue #151 + experiment (ADR-0014). The bulk COM cell reader is a benchmark prototype; + public extraction reads saved cell values through the file backend. +- A new Excel app is owned before opening the workbook, so opening failures + still run app cleanup. Existing workbooks/apps are borrowed and never closed + or terminated by extraction. + - A supported `light` OOXML extraction owns one `OoxmlExtractionSession` and one ZIP archive shared by core workbook parsing, drawing/chart extraction, table candidates and final model construction. The session closes in a diff --git a/dev-docs/specs/extraction-performance.md b/dev-docs/specs/extraction-performance.md index bc2464f3..0a7fa4e9 100644 --- a/dev-docs/specs/extraction-performance.md +++ b/dev-docs/specs/extraction-performance.md @@ -1,5 +1,25 @@ # Extraction performance measurements +## Issue #151 COM-first evaluation + +`benchmark/issue151_com_first.py` compares public file-first extraction with an +internal COM-first candidate using bulk `Value2` and batched error masks. +Tables and merged/formula metadata retain the shared openpyxl backend. +Cold creates and quits Excel per extraction; warm opens/closes the workbook +inside a benchmark-owned running app; already-open borrows the open workbook. +Warm deliberately injects app reuse into both runners; the public helper itself +creates a new app when the target workbook is not already open. +Three repeats alternate runner order. --no-colors explicitly disables the +existing per-cell color stage for the separately reported large/many-sheet +verbose isolation run; it is not the verbose default. Record total, COM acquisition and cleanup +separately; the residual includes file parsing, extraction and model construction. +Report failed COM runs separately, never as faster successful extractions. +The runner checks that Excel app IDs return to their initial empty set. +`benchmark/issue151_compatibility.py` probes date serials, formula recalculation, +unsaved values, errors and hyperlinks. Results and the retention decision are in +`benchmark/issue151-results.md` and ADR-0014. These scripts are sequential +experiments and do not select the public production backend. + Issue [#143](https://github.com/harumiWeb/exstruct/issues/143), part of #142, establishes the baseline before production extraction optimizations. The repository-local `benchmark.performance` module is separate from `bench`'s diff --git a/src/exstruct/core/backends/com_backend.py b/src/exstruct/core/backends/com_backend.py index 5c5c36b7..2f8add49 100644 --- a/src/exstruct/core/backends/com_backend.py +++ b/src/exstruct/core/backends/com_backend.py @@ -8,21 +8,47 @@ import xlwings as xw -from ...models import PrintArea +from ...models import CellRow, PrintArea from ..cells import ( WorkbookColorsMap, WorkbookFormulasMap, + _normalize_cell_value, extract_sheet_colors_map_com, extract_sheet_formulas_map_com, ) from ..charts import get_charts from ..ranges import parse_range_zero_based from ..shapes import get_shapes_with_position -from .base import ChartData, MergedCellData, PrintAreaData, RichBackend, ShapeData +from .base import ( + CellData, + ChartData, + MergedCellData, + PrintAreaData, + RichBackend, + ShapeData, +) logger = logging.getLogger(__name__) +def _sheet_cell_links(sheet: Any) -> dict[int, dict[str, str]]: # noqa: ANN401 + """Read external cell links; skip shapes by type and propagate COM failures.""" + links: dict[int, dict[str, str]] = {} + for link in sheet.api.Hyperlinks: + target = link.Address + if not target: + continue + if link.Type != 0: # msoHyperlinkRange; shape links have no cell anchor. + continue + anchor = link.Range + for row in range(int(anchor.Row), int(anchor.Row + anchor.Rows.Count)): + for col in range( + int(anchor.Column), int(anchor.Column + anchor.Columns.Count) + ): + links.setdefault(row, {})[str(col - 1)] = str(target) + return links + + @dataclass(frozen=True) class ComBackend: """COM-based backend for extraction tasks. @@ -33,6 +59,55 @@ class ComBackend: workbook: xw.Book + def extract_cells(self, *, include_links: bool = False) -> CellData: + """Prototype bulk Value2 reader; intentionally unused by public extraction. + + Read values in bounded row batches without per-cell COM access. Value2 + exposes live values and date serials, so cached/date compatibility must + be evaluated before this reader can replace the file-based reader. + """ + result: CellData = {} + for sheet in self.workbook.sheets: + used = sheet.api.UsedRange + first_row, first_col = int(used.Row), int(used.Column) + height, width = int(used.Rows.Count), int(used.Columns.Count) + batch_rows = max(1, 100_000 // width) + links = _sheet_cell_links(sheet) if include_links else {} + rows: list[CellRow] = [] + for offset in range(0, height, batch_rows): + count = min(batch_rows, height - offset) + block = sheet.range( + (first_row + offset, first_col), + (first_row + offset + count - 1, first_col + width - 1), + ).api + raw = block.Value2 + matrix = ((raw,),) if count == width == 1 else raw + # Excel error variants share integers with legitimate cell values. + # Evaluate ISERROR in bulk rather than interpreting integer codes. + error_mask = sheet.api.Evaluate(f"ISERROR({block.Address})") + errors = ((error_mask,),) if count == width == 1 else error_mask + for index, values in enumerate(matrix): + row_number = first_row + offset + index + cells: dict[str, int | float | str] = {} + for column, value in enumerate(values): + if errors[index][column]: + continue + if isinstance(value, float) and value.is_integer(): + value = int(value) + normalized = _normalize_cell_value(value) + if normalized is not None: + cells[str(first_col + column - 1)] = normalized + if cells: + rows.append( + CellRow( + r=row_number, + c=cells, + links=links.get(row_number) or None, + ) + ) + result[sheet.name] = rows + return result + def extract_print_areas(self) -> PrintAreaData: """Extract print areas per sheet via xlwings/COM. diff --git a/src/exstruct/core/workbook.py b/src/exstruct/core/workbook.py index 7d25fff3..4eea3c27 100644 --- a/src/exstruct/core/workbook.py +++ b/src/exstruct/core/workbook.py @@ -88,18 +88,24 @@ def xlwings_workbook(file_path: Path, *, visible: bool = False) -> Iterator[xw.B return app = xw.App(add_book=False, visible=visible) - wb = app.books.open(str(file_path)) + wb: Any | None = None try: + wb = app.books.open(str(file_path)) yield wb finally: - try: - wb.close() - except Exception as exc: - logger.debug("Failed to close Excel workbook. (%r)", exc) + if wb is not None: + try: + wb.close() + except Exception as exc: + logger.debug("Failed to close Excel workbook. (%r)", exc) try: app.quit() except Exception as exc: logger.debug("Failed to quit Excel application. (%r)", exc) + try: + app.kill() + except Exception as kill_exc: + logger.debug("Failed to kill Excel application. (%r)", kill_exc) def _find_open_workbook(file_path: Path) -> xw.Book | None: diff --git a/tasks/lessons.md b/tasks/lessons.md index ab62ce16..1108ab5d 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,79 +1,79 @@ -## 2026-02-27 Review Fix Lessons - -- When introducing structured error wrappers (`PatchOpError`), re-check outer fallback branches (`backend=auto`) so resilience paths are not accidentally bypassed. -- For `failed_field` inference from message text, avoid single hard-coded mapping for shared phrases like `sheet not found`; infer from contextual tokens (`category`) when available. - -## 2026-02-27 apply_table_style COM compatibility lessons - -- If helper functions are introduced to normalize API variants (property/callable), remove or relax pre-checks at call sites that can short-circuit before the helper runs. -- For COM compatibility fixes, add at least one integration-adjacent unit test at the higher-level caller path, not only helper-level tests. - -## 2026-02-28 spec/implementation alignment lessons - -- When a feature spec changes policy (for example, mixed-op allow/deny), re-run a direct implementation-vs-spec check before reporting completion. -- For policy flips, add one positive test and one negative boundary test in the same change so behavior drift is detected immediately. - -## 2026-02-28 capture_sheet_images timeout hardening lessons - -- For long-running COM/render paths exposed via MCP, always add an explicit tool-level timeout so client-side disconnects do not look like random transport failures. -- In multiprocessing paths, do not use unbounded `join()`; enforce `join(timeout)` and terminate/kill fallback to avoid hung workers blocking COM cleanup. - -## 2026-02-28 non-finite timeout validation lessons - -- For any env-based timeout parsed via `float()`, always reject non-finite values (`NaN`, `inf`, `-inf`) with `math.isfinite(...)` before range checks. -- When adding timeout hardening, include explicit regression tests for `NaN/inf/-inf`; testing only invalid strings and `<= 0` is insufficient. - -## 2026-03-03 subprocess wait-order regression lessons - -- In multi-stage timeout flows, define one primary end-to-end budget explicitly (here: join timeout) and ensure secondary timeouts are only local grace windows. -- When changing wait order, add regression tests that exceed the secondary timeout while staying inside the primary timeout to prevent accidental global timeout shrinkage. - -## 2026-03-03 capture evaluation modal-dialog lessons - -- For unattended Excel render evaluations, do not use fixed `A1:A1` as the minimal-range case; select a known non-empty single cell per workbook. -- Add a run-validity rule for Excel modal dialogs (invalid run + rerun), otherwise stability metrics can be overstated. -- In render paths that open Excel for export, explicitly set `app.display_alerts = False` even if other paths already do so. - -## 2026-03-06 libreoffice/ooxml review lessons - -- When mapping OOXML connector semantics into internal arrow fields, verify `head`/`tail` against the source spec instead of inferring from names alone; add separate start/end regression tests. -- If `__enter__` allocates temp resources before a subprocess probe, clean them up in the exception path as well; `__exit__` is not guaranteed to run on enter failure. - -## 2026-03-06 libreoffice validator contract lessons - -- When composing higher-level validators from lower-level ones, keep each validator sound on its own contract; do not suppress a lower-level check just to improve a combined error path unless the caller fully re-implements that check. -- If a validator has branching for combined invalid options, add a direct unit test for the single-option branch and the combined branch so downstream callers do not mask a contract hole. - -## 2026-03-06 docs parity lessons - -- When changing a public README example or CLI/API option in `README.md`, update `README.ja.md` in the same change before reporting completion. -- For token/serialization policy changes, check both English and Japanese quick-start sections for parity on defaults and opt-in flags. - - -## 2026-03-10 libreoffice smoke gate retry lessons - -- For Windows cold-start runtime checks, avoid single-shot `soffice --version` gating with a short timeout; add an explicit longer retry before declaring runtime unavailable. -- If a fallback probe is expensive (full session startup), place a cheaper retry tier ahead of it to reduce false negatives under CI install jitter. - -## 2026-03-13 ADR governance contract alignment lessons - -- When a shared policy document defines a required output artifact (here: the `specs`/`src`/`tests` evidence triad), mirror that requirement in every dependent skill contract; do not assume downstream docs will fill the gap. -- In decision workflows, collect verification evidence before any terminal verdict, including negative outcomes like `not-needed`; otherwise the process silently permits ungrounded dismissals. - -## 2026-03-13 ADR index contract lessons - -- When a spec makes structured audit fields mandatory (for example `scope` or finding `type`), copy those exact fields into the producing skill contract; partial paraphrases in workflow docs are not enough. -- If a human-facing artifact needs one canonical label while machine-readable metadata supports multiple labels, encode the canonical label explicitly (for example `primary_domain`) instead of inferring it from array order or merged headings. - -## 2026-03-13 ADR reviewer scope and gating lessons - -- When a review skill is responsible for compatibility or public break judgment, make the relevant public `docs/` pages part of its required scope; internal specs alone are not enough evidence. -- When lint and design review are split into separate skills, encode a clean linter result as an explicit precondition in the skill, spec, and workflow docs so `ready` cannot bypass mandatory structural checks. - -## 2026-03-13 AGENTS retention policy lessons - -- When AGENTS explains how to preserve or migrate durable documentation, explicitly direct agents to the relevant repository skills; otherwise the ADR workflow is easy to bypass with ad hoc manual judgment. - +## 2026-02-27 Review Fix Lessons + +- When introducing structured error wrappers (`PatchOpError`), re-check outer fallback branches (`backend=auto`) so resilience paths are not accidentally bypassed. +- For `failed_field` inference from message text, avoid single hard-coded mapping for shared phrases like `sheet not found`; infer from contextual tokens (`category`) when available. + +## 2026-02-27 apply_table_style COM compatibility lessons + +- If helper functions are introduced to normalize API variants (property/callable), remove or relax pre-checks at call sites that can short-circuit before the helper runs. +- For COM compatibility fixes, add at least one integration-adjacent unit test at the higher-level caller path, not only helper-level tests. + +## 2026-02-28 spec/implementation alignment lessons + +- When a feature spec changes policy (for example, mixed-op allow/deny), re-run a direct implementation-vs-spec check before reporting completion. +- For policy flips, add one positive test and one negative boundary test in the same change so behavior drift is detected immediately. + +## 2026-02-28 capture_sheet_images timeout hardening lessons + +- For long-running COM/render paths exposed via MCP, always add an explicit tool-level timeout so client-side disconnects do not look like random transport failures. +- In multiprocessing paths, do not use unbounded `join()`; enforce `join(timeout)` and terminate/kill fallback to avoid hung workers blocking COM cleanup. + +## 2026-02-28 non-finite timeout validation lessons + +- For any env-based timeout parsed via `float()`, always reject non-finite values (`NaN`, `inf`, `-inf`) with `math.isfinite(...)` before range checks. +- When adding timeout hardening, include explicit regression tests for `NaN/inf/-inf`; testing only invalid strings and `<= 0` is insufficient. + +## 2026-03-03 subprocess wait-order regression lessons + +- In multi-stage timeout flows, define one primary end-to-end budget explicitly (here: join timeout) and ensure secondary timeouts are only local grace windows. +- When changing wait order, add regression tests that exceed the secondary timeout while staying inside the primary timeout to prevent accidental global timeout shrinkage. + +## 2026-03-03 capture evaluation modal-dialog lessons + +- For unattended Excel render evaluations, do not use fixed `A1:A1` as the minimal-range case; select a known non-empty single cell per workbook. +- Add a run-validity rule for Excel modal dialogs (invalid run + rerun), otherwise stability metrics can be overstated. +- In render paths that open Excel for export, explicitly set `app.display_alerts = False` even if other paths already do so. + +## 2026-03-06 libreoffice/ooxml review lessons + +- When mapping OOXML connector semantics into internal arrow fields, verify `head`/`tail` against the source spec instead of inferring from names alone; add separate start/end regression tests. +- If `__enter__` allocates temp resources before a subprocess probe, clean them up in the exception path as well; `__exit__` is not guaranteed to run on enter failure. + +## 2026-03-06 libreoffice validator contract lessons + +- When composing higher-level validators from lower-level ones, keep each validator sound on its own contract; do not suppress a lower-level check just to improve a combined error path unless the caller fully re-implements that check. +- If a validator has branching for combined invalid options, add a direct unit test for the single-option branch and the combined branch so downstream callers do not mask a contract hole. + +## 2026-03-06 docs parity lessons + +- When changing a public README example or CLI/API option in `README.md`, update `README.ja.md` in the same change before reporting completion. +- For token/serialization policy changes, check both English and Japanese quick-start sections for parity on defaults and opt-in flags. + + +## 2026-03-10 libreoffice smoke gate retry lessons + +- For Windows cold-start runtime checks, avoid single-shot `soffice --version` gating with a short timeout; add an explicit longer retry before declaring runtime unavailable. +- If a fallback probe is expensive (full session startup), place a cheaper retry tier ahead of it to reduce false negatives under CI install jitter. + +## 2026-03-13 ADR governance contract alignment lessons + +- When a shared policy document defines a required output artifact (here: the `specs`/`src`/`tests` evidence triad), mirror that requirement in every dependent skill contract; do not assume downstream docs will fill the gap. +- In decision workflows, collect verification evidence before any terminal verdict, including negative outcomes like `not-needed`; otherwise the process silently permits ungrounded dismissals. + +## 2026-03-13 ADR index contract lessons + +- When a spec makes structured audit fields mandatory (for example `scope` or finding `type`), copy those exact fields into the producing skill contract; partial paraphrases in workflow docs are not enough. +- If a human-facing artifact needs one canonical label while machine-readable metadata supports multiple labels, encode the canonical label explicitly (for example `primary_domain`) instead of inferring it from array order or merged headings. + +## 2026-03-13 ADR reviewer scope and gating lessons + +- When a review skill is responsible for compatibility or public break judgment, make the relevant public `docs/` pages part of its required scope; internal specs alone are not enough evidence. +- When lint and design review are split into separate skills, encode a clean linter result as an explicit precondition in the skill, spec, and workflow docs so `ready` cannot bypass mandatory structural checks. + +## 2026-03-13 AGENTS retention policy lessons + +- When AGENTS explains how to preserve or migrate durable documentation, explicitly direct agents to the relevant repository skills; otherwise the ADR workflow is easy to bypass with ad hoc manual judgment. + ## 2026-03-13 ADR review follow-up lessons - When a template defines a required section such as `状態`, mirror that exact requirement in the producing or linting skill checklist; validating only the value is not enough if the section itself can be omitted. @@ -117,3 +117,10 @@ - When a mode contract changes in `extract(...)`, re-check one-shot export surfaces (`process_excel`, CLI, engine side-output paths) so output filtering defaults do not silently drift from the accepted public behavior. - For workbook-wide parsers that aggregate sheet artifacts, keep exception boundaries at the smallest safe unit; a single malformed sheet-level part should not clear healthy sheet results unless the workbook container itself is unreadable. + + +## 2026-10-03 COM benchmark isolation + +- Run real Excel tests and COM benchmarks sequentially. Concurrent Excel owners can contaminate process-lifecycle and timing evidence; discard and rerun overlapping scenarios. +- Exclude COM failure/fallback samples from successful extraction medians even when their serialized output happens to match. +- Pytest COM session setup creates and quits an Excel availability-probe app before tests. Never start pytest (including COM availability checks) while any timed Excel run is active. diff --git a/tests/backends/test_com_bulk_cells.py b/tests/backends/test_com_bulk_cells.py new file mode 100644 index 00000000..a21cde9b --- /dev/null +++ b/tests/backends/test_com_bulk_cells.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from collections.abc import Sequence + +import pytest + +from exstruct.core.backends.com_backend import ComBackend +from exstruct.models import CellRow + + +class _Count: + def __init__(self, count: int) -> None: + self.Count = count + + +class _UsedRange: + def __init__(self, row: int, column: int, height: int, width: int) -> None: + self.Row = row + self.Column = column + self.Rows = _Count(height) + self.Columns = _Count(width) + + +class _Anchor: + def __init__(self, row: int, column: int, height: int = 1, width: int = 1) -> None: + self.Row = row + self.Column = column + self.Rows = _Count(height) + self.Columns = _Count(width) + + +class _Hyperlink: + def __init__( + self, + address: str, + anchor: _Anchor, + subaddress: str = "", + *, + link_type: int = 0, + range_error: Exception | None = None, + ) -> None: + self.Address = address + self.Type = link_type + self._anchor = anchor + self._range_error = range_error + self.SubAddress = subaddress + + @property + def Range(self) -> _Anchor: + if self._range_error is not None: + raise self._range_error + return self._anchor + + +class _FakeRange: + def __init__( + self, + sheet: _FakeSheet, + first_row: int, + first_column: int, + last_row: int, + last_column: int, + ) -> None: + self.sheet = sheet + self.first_row = first_row + self.first_column = first_column + self.last_row = last_row + self.last_column = last_column + self.Address = f"R{first_row}C{first_column}:R{last_row}C{last_column}" + self.api = self + + @property + def Value2(self) -> object: + if self.sheet.value_error is not None: + raise self.sheet.value_error + values = self._slice(self.sheet.values) + if len(values) == 1 and len(values[0]) == 1: + return values[0][0] + return tuple(tuple(row) for row in values) + + def error_values(self) -> tuple[tuple[bool, ...], ...]: + return tuple(tuple(row) for row in self._slice(self.sheet.errors)) + + def _slice(self, source: Sequence[Sequence[object]]) -> list[list[object]]: + row_offset = self.first_row - self.sheet.first_row + column_offset = self.first_column - self.sheet.first_column + height = self.last_row - self.first_row + 1 + width = self.last_column - self.first_column + 1 + return [ + list(source[row][column_offset : column_offset + width]) + for row in range(row_offset, row_offset + height) + ] + + +class _SheetApi: + def __init__(self, sheet: _FakeSheet) -> None: + self.sheet = sheet + self.UsedRange = _UsedRange( + sheet.first_row, + sheet.first_column, + len(sheet.values), + len(sheet.values[0]), + ) + self.Hyperlinks = sheet.hyperlinks + + def Evaluate(self, formula: str) -> object: + if self.sheet.evaluate_error is not None: + raise self.sheet.evaluate_error + expected = f"ISERROR({self.sheet.last_range.Address})" + assert formula == expected + errors = self.sheet.last_range.error_values() + if len(errors) == 1 and len(errors[0]) == 1: + return errors[0][0] + return errors + + +class _FakeSheet: + def __init__( + self, + values: list[list[object]], + *, + first_row: int = 1, + first_column: int = 1, + errors: list[list[bool]] | None = None, + hyperlinks: list[_Hyperlink] | None = None, + value_error: Exception | None = None, + evaluate_error: Exception | None = None, + name: str = "Data", + ) -> None: + self.name = name + self.values = values + self.first_row = first_row + self.first_column = first_column + self.errors = errors or [[False for _ in row] for row in values] + self.hyperlinks = hyperlinks or [] + self.value_error = value_error + self.evaluate_error = evaluate_error + self.last_range: _FakeRange + self.api = _SheetApi(self) + + def range(self, first: tuple[int, int], last: tuple[int, int]) -> _FakeRange: + self.last_range = _FakeRange(self, *first, *last) + return self.last_range + + +class _FakeWorkbook: + def __init__(self, *sheets: _FakeSheet) -> None: + self.sheets = list(sheets) + + +def test_com_extract_cells_preserves_offsets_and_normalizes_values() -> None: + error_code = 2042 + sheet = _FakeSheet( + [ + [error_code, 12.0, "42", True, None, "NA"], + [error_code, 2.75, False, "hello", "N/A", " "], + ], + first_row=7, + first_column=4, + errors=[ + [False, False, False, False, False, False], + [True, False, False, False, False, False], + ], + ) + + rows = ComBackend(_FakeWorkbook(sheet)).extract_cells()["Data"] + + assert rows == [ + CellRow( + r=7, + c={"3": error_code, "4": 12, "5": 42, "6": "True"}, + ), + CellRow(r=8, c={"4": 2.75, "5": "False", "6": "hello"}), + ] + + +@pytest.mark.parametrize( + ("values", "first_row", "first_column", "expected"), + [ + ([[3.0]], 9, 6, [CellRow(r=9, c={"5": 3})]), + ([["left", "right"]], 4, 3, [CellRow(r=4, c={"2": "left", "3": "right"})]), + ( + [[1], [2]], + 5, + 2, + [CellRow(r=5, c={"1": 1}), CellRow(r=6, c={"1": 2})], + ), + ], + ids=["scalar", "one-row", "one-column"], +) +def test_com_extract_cells_handles_com_array_shapes( + values: list[list[object]], + first_row: int, + first_column: int, + expected: list[CellRow], +) -> None: + sheet = _FakeSheet(values, first_row=first_row, first_column=first_column) + + assert ComBackend(_FakeWorkbook(sheet)).extract_cells()["Data"] == expected + + +def test_com_extract_cells_masks_errors_without_dropping_equal_integer() -> None: + error_code = 2042 + sheet = _FakeSheet( + [[error_code, error_code]], + errors=[[False, True]], + ) + + rows = ComBackend(_FakeWorkbook(sheet)).extract_cells()["Data"] + + assert rows == [CellRow(r=1, c={"0": error_code})] + + +def test_com_extract_cells_includes_external_links_but_omits_internal_and_blank_rows() -> ( + None +): + sheet = _FakeSheet( + [["linked", None], [None, None], [None, "internal"]], + first_row=10, + first_column=3, + hyperlinks=[ + _Hyperlink("https://example.test/page", _Anchor(10, 4)), + _Hyperlink("https://blank.test/", _Anchor(11, 3)), + _Hyperlink("", _Anchor(12, 3), subaddress="Other!A1"), + ], + ) + + rows = ComBackend(_FakeWorkbook(sheet)).extract_cells(include_links=True)["Data"] + + assert rows == [ + CellRow( + r=10, + c={"2": "linked"}, + links={"3": "https://example.test/page"}, + ), + CellRow(r=12, c={"3": "internal"}), + ] + + +@pytest.mark.parametrize("failure_point", ["Value2", "Evaluate"]) +def test_com_extract_cells_propagates_com_read_errors(failure_point: str) -> None: + failure = RuntimeError(f"{failure_point} failed") + sheet = _FakeSheet( + [["value"]], + value_error=failure if failure_point == "Value2" else None, + evaluate_error=failure if failure_point == "Evaluate" else None, + ) + + with pytest.raises(RuntimeError, match=f"{failure_point} failed"): + ComBackend(_FakeWorkbook(sheet)).extract_cells() + + +def test_com_extract_cells_batches_large_rectangles() -> None: + sheet = _FakeSheet([[None] * 2 for _ in range(50_001)]) + sheet.values[0][0] = "first" + sheet.values[-1][1] = "last" + assert ComBackend(_FakeWorkbook(sheet)).extract_cells()["Data"] == [ + CellRow(r=1, c={"0": "first"}), + CellRow(r=50_001, c={"1": "last"}), + ] + assert sheet.last_range.first_row == 50_001 + assert sheet.last_range.last_row == 50_001 + + +def test_com_extract_cells_skips_shape_links_without_reading_range() -> None: + sheet = _FakeSheet( + [["value"]], + hyperlinks=[ + _Hyperlink( + "https://shape.test/", + _Anchor(1, 1), + link_type=1, + range_error=AssertionError("Shape links have no Range"), + ), + _Hyperlink("https://cell.test/", _Anchor(1, 1)), + ], + ) + + assert ComBackend(_FakeWorkbook(sheet)).extract_cells(include_links=True)[ + "Data" + ] == [CellRow(r=1, c={"0": "value"}, links={"0": "https://cell.test/"})] + + +def test_com_extract_cells_propagates_cell_link_range_failure() -> None: + sheet = _FakeSheet( + [["value"]], + hyperlinks=[ + _Hyperlink( + "https://cell.test/", + _Anchor(1, 1), + range_error=RuntimeError("Cell link Range failed"), + ) + ], + ) + + with pytest.raises(RuntimeError, match="Cell link Range failed"): + ComBackend(_FakeWorkbook(sheet)).extract_cells(include_links=True) diff --git a/tests/benchmark/test_issue151_com_first.py b/tests/benchmark/test_issue151_com_first.py new file mode 100644 index 00000000..0eb9df29 --- /dev/null +++ b/tests/benchmark/test_issue151_com_first.py @@ -0,0 +1,179 @@ +"""Failure isolation for the non-public COM-first experiment.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from benchmark import issue151_com_first as experiment +import pytest + +from exstruct.core import pipeline +from exstruct.core.modeling import SheetRawData +from exstruct.errors import FallbackReason +from exstruct.models import PrintArea + + +@pytest.mark.parametrize("failure", ["startup", "cells", "rich", "model"]) +def test_experiment_falls_back_after_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + from openpyxl import Workbook + + path = tmp_path / "fallback.xlsx" + book = Workbook() + book.active["B3"] = "saved" + book.save(path) + book.close() + inputs = experiment.inputs_for(path, "standard") + monkeypatch.setenv("SKIP_COM_TESTS", "1") + expected = pipeline._run_openpyxl_pipeline(inputs) + monkeypatch.delenv("SKIP_COM_TESTS") + events = [] + + @contextmanager + def com_scope(_: Path) -> Iterator[Any]: + if failure == "startup": + raise RuntimeError("startup") + try: + yield object() + finally: + events.append("closed") + + def cells(*args: object, **kwargs: object) -> dict[str, list[Any]]: + if failure == "cells": + raise RuntimeError("cells") + return {"partial": []} + + def rich(*args: object, **kwargs: object) -> None: + if failure == "rich": + raise RuntimeError("rich") + + def model(*args: object, **kwargs: object) -> None: + raise RuntimeError("model") + + original = pipeline._run_openpyxl_pipeline + + def fallback(resolved: pipeline.ExtractionInputs) -> pipeline.PipelineResult: + assert events == ([] if failure == "startup" else ["closed"]) + events.append("fallback") + return original(resolved) + + monkeypatch.setattr(pipeline, "xlwings_workbook", com_scope) + monkeypatch.setattr(experiment.ComBackend, "extract_cells", cells) + monkeypatch.setattr(pipeline, "run_com_pipeline", rich) + monkeypatch.setattr(pipeline, "collect_sheet_raw_data", lambda **kwargs: {}) + monkeypatch.setattr(experiment, "build_workbook_data", model) + monkeypatch.setattr(pipeline, "_run_openpyxl_pipeline", fallback) + result = experiment.com_first(inputs) + assert result.workbook == expected.workbook + assert result.artifacts.cell_data == expected.artifacts.cell_data + assert result.state.com_attempted == (failure != "startup") + assert not result.state.com_succeeded + assert result.state.fallback_reason == ( + FallbackReason.COM_UNAVAILABLE + if failure == "startup" + else FallbackReason.COM_PIPELINE_FAILED + ) + import os + + assert "SKIP_COM_TESTS" not in os.environ + + +@pytest.mark.parametrize("candidate_success, expected", [(False, None), (True, True)]) +def test_compatibility_requires_success_on_both_sides( + candidate_success: bool, expected: bool | None +) -> None: + records = [ + {"backend": "current", "com_succeeded": True, "output_sha256": "saved"}, + { + "backend": "com-first", + "com_succeeded": candidate_success, + "output_sha256": "saved", + }, + {"backend": "com-first", "com_succeeded": False, "output_sha256": "fallback"}, + ] + assert experiment.output_compatible(records) is expected + + +@pytest.mark.parametrize( + "saved_area, include_areas", [(True, True), (False, True), (True, False)] +) +def test_experiment_preserves_saved_print_area_when_com_read_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + saved_area: bool, + include_areas: bool, +) -> None: + from openpyxl import Workbook + + path = tmp_path / "print-area.xlsx" + book = Workbook() + book.active["B3"] = "saved" + if saved_area: + book.active.print_area = "A1:D20" + book.save(path) + book.close() + inputs = replace( + experiment.inputs_for(path, "standard"), include_print_areas=include_areas + ) + monkeypatch.delenv("SKIP_COM_TESTS", raising=False) + com_reads = [] + + class FailingPageSetup: + @property + def PrintArea(self) -> str: + com_reads.append("print area") + raise RuntimeError("PrintArea unavailable") + + @contextmanager + def com_scope(_: Path) -> Iterator[Any]: + yield SimpleNamespace( + sheets=[ + SimpleNamespace( + name="Sheet", api=SimpleNamespace(PageSetup=FailingPageSetup()) + ) + ] + ) + + def rich( + steps: object, + resolved: pipeline.ExtractionInputs, + artifacts: pipeline.ExtractionArtifacts, + workbook: object, + ) -> None: + if resolved.include_print_areas: + pipeline.step_extract_print_areas_com(resolved, artifacts, workbook) + + def collect( + *, print_area_data: dict[str, list[PrintArea]], **kwargs: object + ) -> dict[str, SheetRawData]: + return { + "Sheet": SheetRawData( + rows=[], + shapes=[], + charts=[], + table_candidates=[], + print_areas=print_area_data.get("Sheet", []), + auto_print_areas=[], + formulas_map={}, + colors_map={}, + merged_cells=[], + ) + } + + monkeypatch.setattr(pipeline, "xlwings_workbook", com_scope) + monkeypatch.setattr(experiment.ComBackend, "extract_cells", lambda *a, **kw: {}) + monkeypatch.setattr(pipeline, "run_com_pipeline", rich) + monkeypatch.setattr(pipeline, "collect_sheet_raw_data", collect) + + result = experiment.com_first(inputs) + + expected = ( + [PrintArea(r1=1, c1=0, r2=20, c2=3)] if saved_area and include_areas else [] + ) + assert result.workbook.sheets["Sheet"].print_areas == expected + assert result.state.com_succeeded + assert com_reads == (["print area"] if include_areas and not saved_area else []) diff --git a/tests/com/test_issue151_bulk_compatibility.py b/tests/com/test_issue151_bulk_compatibility.py new file mode 100644 index 00000000..8ead6c83 --- /dev/null +++ b/tests/com/test_issue151_bulk_compatibility.py @@ -0,0 +1,27 @@ +"""Real Excel evidence for the experimental cell reader's adoption gate.""" + +from benchmark.issue151_compatibility import probe +import pytest + + +@pytest.mark.com +def test_bulk_com_compatibility_gate() -> None: + result = probe() + assert result["current_com_succeeded"] + assert result["candidate_com_succeeded"] + assert not result["full_output_equal"] + saved = result["file_cells"]["Sheet"] + live = result["com_cells"]["Sheet"] + unsaved = result["unsaved_com_cells"]["Sheet"] + assert saved[1]["c"] == {"1": "2024-01-02 03:04:05"} + assert isinstance(live[1]["c"]["1"], float) + assert not any(row["r"] == 3 for row in saved) + assert next(row for row in live if row["r"] == 3)["c"] == {"2": 3} + assert unsaved[0]["c"] == {"0": "unsaved"} + for rows in (saved, live): + assert not any(row["r"] == 4 for row in rows) + assert next(row for row in rows if row["r"] == 5)["c"] == {"4": -2146826281} + assert next(row for row in rows if row["r"] == 7)["links"] == { + "6": "https://example.test/" + } + assert next(row for row in rows if row["r"] == 8)["links"] is None diff --git a/tests/core/test_workbook_utils.py b/tests/core/test_workbook_utils.py index b43a9a49..01bf07f5 100644 --- a/tests/core/test_workbook_utils.py +++ b/tests/core/test_workbook_utils.py @@ -29,24 +29,142 @@ def _fake_load_workbook(*_args: object, **_kwargs: object) -> _DummyWorkbook: assert wb is dummy -def test_xlwings_workbook_uses_existing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_xlwings_workbook_leaves_existing_workbook_and_app_open( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: class _DummyBook: - pass + close_calls = 0 + fullname = str(tmp_path / "book.xlsx") + + def close(self) -> None: + self.close_calls += 1 dummy = _DummyBook() - def _fake_find(_path: Path) -> _DummyBook: - return dummy + class _DummyApp: + books = [dummy] + quit_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + + app = _DummyApp() def _boom(*_args: object, **_kwargs: object) -> None: raise AssertionError("xlwings App should not be created when existing") - monkeypatch.setattr(workbook, "_find_open_workbook", _fake_find) + monkeypatch.setattr(workbook.xw, "apps", [app]) monkeypatch.setattr(workbook.xw, "App", _boom) - with workbook.xlwings_workbook(Path("book.xlsx")) as wb: + with workbook.xlwings_workbook(tmp_path / "book.xlsx") as wb: assert wb is dummy + assert dummy.close_calls == 0 + assert app.quit_calls == 0 + + +def test_xlwings_workbook_quits_app_when_open_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + class _DummyBooks: + def open(self, _path: str) -> None: + raise RuntimeError("open failed") + + class _DummyApp: + books = _DummyBooks() + quit_calls = 0 + kill_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + + def kill(self) -> None: + self.kill_calls += 1 + + app = _DummyApp() + monkeypatch.setattr(workbook, "_find_open_workbook", lambda _path: None) + monkeypatch.setattr(workbook.xw, "App", lambda **_kwargs: app) + + with pytest.raises(RuntimeError, match="open failed"): + with workbook.xlwings_workbook(tmp_path / "book.xlsx"): + pytest.fail("open failure must prevent yielding") + + assert app.quit_calls == 1 + assert app.kill_calls == 0 + + +def test_xlwings_workbook_closes_owned_book_after_extraction_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + class _DummyBook: + close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + + class _DummyBooks: + def __init__(self, book: _DummyBook) -> None: + self._book = book + + def open(self, _path: str) -> _DummyBook: + return self._book + + class _DummyApp: + def __init__(self, book: _DummyBook) -> None: + self.books = _DummyBooks(book) + self.quit_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + + book = _DummyBook() + app = _DummyApp(book) + monkeypatch.setattr(workbook, "_find_open_workbook", lambda _path: None) + monkeypatch.setattr(workbook.xw, "App", lambda **_kwargs: app) + + with pytest.raises(RuntimeError, match="extraction failed"): + with workbook.xlwings_workbook(tmp_path / "book.xlsx"): + raise RuntimeError("extraction failed") + + assert book.close_calls == 1 + assert app.quit_calls == 1 + + +@pytest.mark.parametrize("close_fails", [False, True]) +def test_xlwings_workbook_kills_owned_app_when_quit_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, close_fails: bool +) -> None: + class _DummyBook: + def close(self) -> None: + if close_fails: + raise RuntimeError("close failed") + + class _DummyBooks: + def open(self, _path: str) -> _DummyBook: + return _DummyBook() + + class _DummyApp: + books = _DummyBooks() + quit_calls = 0 + kill_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + raise RuntimeError("quit failed") + + def kill(self) -> None: + self.kill_calls += 1 + + app = _DummyApp() + monkeypatch.setattr(workbook, "_find_open_workbook", lambda _path: None) + monkeypatch.setattr(workbook.xw, "App", lambda **_kwargs: app) + + with workbook.xlwings_workbook(tmp_path / "book.xlsx"): + pass + + assert app.quit_calls == 1 + assert app.kill_calls == 1 + def test_find_open_workbook_handles_fullname_error( monkeypatch: pytest.MonkeyPatch,