diff --git a/METHODOLOGY.md b/METHODOLOGY.md index 771230f..cb7b574 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -135,6 +135,12 @@ oracle, oracle version pinned in the artifact). - Status semantics are strict: `passed` requires a full match against the oracle; any wrong or missing value reports `failed` with matched/total and per-cell mismatch details. +- Engines with a calculation API are scored on the values that API returns. + How many correct values the engine's save writes into the workbook is + reported separately as "Saved to file". Engines that calculate only on save, + such as LibreOffice, are scored on the saved file (DEC-028). +- Every engine result records the engine version; builds not installed from a + package registry are labeled as local, editable, or VCS builds. - Formulas avoid `IFS` and `XLOOKUP` because the LibreOffice oracle build returns `#NAME?` for them on this path; nested `IF` and INDEX/MATCH are used instead. This constrains function coverage and is documented per artifact. diff --git a/README.md b/README.md index 829490b..617762c 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Use this when the question is: which engine actually computes a 133-formula fina - **Speed vs fidelity tradeoff is measurable**: use the perf snapshot together with the fidelity matrix rather than quoting one without the other - **Optimization modes have clear costs**: openpyxl-readonly loses 13 green features for streaming speed - **Cross-language context is now strong too**: `Apache POI` and `Excelize` land at `18/18` in the scored write lane; `zavora-xlsx` 0.1.2 writes fast but corrupts hyperlink relationships on mutate and cannot recalculate -- **Calculation is a differentiator**: only LibreOffice computes the full 133-formula DAG (2026-10-02 snapshot); aspose-cells-foss covers 25/133; WolfXL 2.0.5 scores 0/133 because `Workbook.calculate()` runs but the saved workbook carries no cached formula values +- **Calculation is a differentiator**: in the 2026-10-02 calc rerun, LibreOffice, WolfXL Community 2.0.7, and WolfXL Commercial 2.3.0 compute all 133 formulas; aspose-cells-foss covers 25/133 and zavora-xlsx 0/133. Community's `save()` writes no calculated values (0/133 saved), while Commercial saves all 133 See the [release snapshot dashboard](results-release-2026-04-28/DASHBOARD.md) for the fresh wheel-backed combined view, or the [historical dashboard](results/DASHBOARD.md) for the older public baseline. diff --git a/architecture.md b/architecture.md index c6a43f7..ce0ebad 100644 --- a/architecture.md +++ b/architecture.md @@ -83,7 +83,10 @@ Most-touched top-level directories: - `harness/adapters/json_model_adapter.py`: base for adapters driven through an external JSON helper (SheetJS, ExcelJS, LibreOffice) - `harness/calc.py`: formula-recalculation tier engines (wolfxl, LibreOffice, - aspose-cells-foss, zavora oracle) against the cache-free fixture + aspose-cells-foss, zavora oracle) against the cache-free fixture; the + wolfxl-commercial engine runs a second `wolfxl` build in the interpreter + named by `EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON` and returns its values as JSON + (see DEC-028) - `modifiable.py`: engine registry for mutation-capable adapters - `harness/external_fixture_specs/`: tool-specific external oracle fixture definitions imported by the fixture-pack generator diff --git a/decisions.md b/decisions.md index a1a0c46..f4f4df3 100644 --- a/decisions.md +++ b/decisions.md @@ -40,6 +40,45 @@ Skip logging for routine bug fixes, refactors, or incremental test additions. ## Decisions +### DEC-028 — Calc tier scores API results; saved values and engine identity are recorded beside them (2026-10-02) + +**Context**: DEC-026 scored each engine from the cached values in the +workbook it saved. For WolfXL that measured `save()`, not calculation: +`Workbook.calculate()` returned values that the saved file never carried, so +WolfXL scored 0/133 while computing them. The results also named no engine +version, and a local WolfXL build was reported as if it were the PyPI +release with the same version string. WolfXL Commercial and Community both +install as `wolfxl`, so one interpreter cannot load both. + +**Decision**: + +- Engines that calculate through an API are scored on the values the API + returns. How many correct values `save()` writes into the workbook is + recorded separately as `saved_matched` and shown in a "Saved to file" + column. Engines that only calculate on save (LibreOffice) keep their + saved-file score. +- Each engine result records `version()`. Non-registry installs carry their + PEP 610 source (local build, editable build, VCS commit) and never an install + path or URL. A failing version probe records `None`. +- `wolfxl-commercial` runs in the interpreter named by + `EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON` and reports values over JSON; without + that variable it is unavailable. +- Calc results carry `metadata.run_date` and `metadata.platform` so a calc + run can be published as its own snapshot. + +**Alternatives considered**: + +1. Keep scoring saved caches - rejected; it reports a persistence gap as a + calculation failure. +2. Install both editions side by side under different names - rejected; the + Commercial wheel is the product users install, unmodified. + +**Consequences**: + +- WolfXL Community 2.0.7 scores 133/133 calculated and 0/133 saved; WolfXL + Commercial 2.3.0 scores 133/133 on both. DEC-026's cache-freedom rule still + holds: no engine can pass by copying input caches. + ### DEC-027 — External-helper adapters are scored; mutation preservation compares parsed content (2026-10-02) **Context**: DEC-021 kept external helpers out of `get_all_adapters()` until a diff --git a/results-2026-10-02/README.md b/results-2026-10-02/README.md index 46a774f..0600921 100644 --- a/results-2026-10-02/README.md +++ b/results-2026-10-02/README.md @@ -4,14 +4,15 @@ | --- | --- | --- | | Fidelity (17 adapters) | [xlsx/](xlsx/README.md) | `excelbench benchmark -t fixtures/excel -o results-2026-10-02/xlsx -a openpyxl -a xlsxwriter -a python-calamine -a aspose-cells-foss -a wolfxl -a pylightxl -a xlrd -a pyexcel -a xlwt -a pandas -a xlsxwriter-constmem -a openpyxl-readonly -a polars -a tablib -a sheetjs -a exceljs -a libreoffice` then `excelbench heatmap -i results-2026-10-02/xlsx/results.json -o results-2026-10-02/xlsx` | | Template mutation | [mutation/](mutation/README.md) | `excelbench mutation -o results-2026-10-02/mutation --repeats 3` | -| Formula recalculation | [calc/](calc/README.md) | `excelbench calc -o results-2026-10-02/calc` | +| Formula recalculation | [calc/](calc/README.md) | `EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON=/bin/python excelbench calc -o results-2026-10-02/calc` (rerun later the same day; see Versions) | | Cross-language context | [cross-language/](cross-language/README.md) | `EXCELBENCH_ORACLE_DOCKER_CONTEXT=pc excelbench cross-language-context -t fixtures/excel -o results-2026-10-02/cross-language` | ## Versions | Library | Version | Runtime | | --- | --- | --- | -| wolfxl | 2.0.5 (latest PyPI release when run) | Python wheel | +| wolfxl | 2.0.5 (latest PyPI release when run); the calc rerun used 2.0.7 | Python wheel | +| wolfxl-commercial (calc only) | 2.3.0 from SynthGL's authenticated index | Python wheel, separate interpreter | | openpyxl / openpyxl-readonly | 3.1.5 | Python | | aspose-cells-foss | 26.7.0 | Python | | xlsxwriter / xlsxwriter-constmem | 3.2.9 | Python | diff --git a/results-2026-10-02/calc/README.md b/results-2026-10-02/calc/README.md index e354a65..48c633b 100644 --- a/results-2026-10-02/calc/README.md +++ b/results-2026-10-02/calc/README.md @@ -2,18 +2,17 @@ Oracle: libreoffice/LibreOffice 26.8.0.3 bce0998afefdbc355585ca324285661a2170ba77 -Formula values are compared with absolute tolerance 1e-6 or relative tolerance 1e-9 for numbers; strings and booleans compare exactly. +Formula values are compared with absolute tolerance 1e-6 or relative tolerance 1e-9 for numbers; strings and booleans compare exactly. Saved to file counts the matching values a save writes into the workbook, for engines that return results through an API. -| Engine | Status | Matched/total | First mismatches | -| --- | --- | --- | --- | -| wolfxl | failed | 0/133 | Schedule!B10: expected 0.0, actual None
Schedule!B11: expected -308872.0, actual None
Schedule!B2: expected 100000.0, actual None
Schedule!B3: expected 38000.0, actual None
Schedule!B4: expected 62000.0, actual None
Schedule!B5: expected 12000.0, actual None
Schedule!B6: expected 488.0, actual None
Schedule!B7: expected 719.0, actual None
Schedule!B8: expected 400872.0, actual None
Schedule!B9: expected -300872.0, actual None | - -wolfxl: 133/133 formula values wrong or missing (first mismatch batch: 10 shown, 10 missing, 0 wrong) -| libreoffice | passed | 133/133 | — | -| aspose_cells_foss | failed | 25/133 | Schedule!B11: expected -308872.0, actual None
Schedule!B2: expected 100000.0, actual None
Schedule!B3: expected 38000.0, actual None
Schedule!B4: expected 62000.0, actual None
Schedule!B6: expected 488.0, actual None
Schedule!B7: expected 719.0, actual None
Schedule!B8: expected 400872.0, actual None
Schedule!B9: expected -300872.0, actual None
Schedule!C11: expected -306442.0, actual None
Schedule!C2: expected 104500.0, actual None | +| Engine | Version | Status | Matched/total | Saved to file | First mismatches | +| --- | --- | --- | --- | --- | --- | +| wolfxl | 2.0.7 | passed | 133/133 | 0/133 | — | +| wolfxl-commercial | 2.3.0 | passed | 133/133 | 133/133 | — | +| libreoffice | 26.8.0.3 (build bce0998afefdbc355585ca324285661a2170ba77) | passed | 133/133 | — | — | +| aspose_cells_foss | 26.7.0 | failed | 25/133 | — | Schedule!B11: expected -308872.0, actual None
Schedule!B2: expected 100000.0, actual None
Schedule!B3: expected 38000.0, actual None
Schedule!B4: expected 62000.0, actual None
Schedule!B6: expected 488.0, actual None
Schedule!B7: expected 719.0, actual None
Schedule!B8: expected 400872.0, actual None
Schedule!B9: expected -300872.0, actual None
Schedule!C11: expected -306442.0, actual None
Schedule!C2: expected 104500.0, actual None | +| zavora | unknown | failed | 0/133 | — | Schedule!B10: expected 0.0, actual None
Schedule!B11: expected -308872.0, actual None
Schedule!B2: expected 100000.0, actual None
Schedule!B3: expected 38000.0, actual None
Schedule!B4: expected 62000.0, actual None
Schedule!B5: expected 12000.0, actual None
Schedule!B6: expected 488.0, actual None
Schedule!B7: expected 719.0, actual None
Schedule!B8: expected 400872.0, actual None
Schedule!B9: expected -300872.0, actual None | aspose_cells_foss: FOSS FormulaEvaluator returned no value for 107 formulas: Schedule!B2, Schedule!C2, Schedule!D2, Schedule!E2, Schedule!F2 -| zavora | failed | 0/133 | Schedule!B10: expected 0.0, actual None
Schedule!B11: expected -308872.0, actual None
Schedule!B2: expected 100000.0, actual None
Schedule!B3: expected 38000.0, actual None
Schedule!B4: expected 62000.0, actual None
Schedule!B5: expected 12000.0, actual None
Schedule!B6: expected 488.0, actual None
Schedule!B7: expected 719.0, actual None
Schedule!B8: expected 400872.0, actual None
Schedule!B9: expected -300872.0, actual None | zavora: 133/133 formula values wrong or missing (first mismatch batch: 10 shown, 10 missing, 0 wrong) @@ -31,6 +30,7 @@ one are **not applicable** here, not scored 0: | excelize 2.10.1 | Not run | Excelize ships `CalcCellValue`, but the ExcelBench Excelize helper has no evaluate operation, so there is no harness path to score it yet | The `libreoffice` row above is LibreOffice 26.8.0.3 headless recalculation (it is also the oracle that produced `fixtures/calc/expected_values.json`). -`wolfxl` is WolfXL 2.0.5 from PyPI: `Workbook.calculate()` runs, but the saved workbook carries no cached formula values, so every scored cell reads back empty. +`wolfxl` is WolfXL Community 2.0.7 from PyPI. Its score counts the values `Workbook.calculate()` returns; its `save()` writes no calculated values, shown as Saved to file 0/133. +`wolfxl-commercial` is WolfXL Commercial 2.3.0 from SynthGL's authenticated package index. Both editions install as `wolfxl`, so it ran in a separate interpreter named by `EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON`. -Command: `excelbench calc -o results-2026-10-02/calc` (defaults: `fixtures/calc/financial_model.xlsx`, `fixtures/calc/expected_values.json`). +Command: `EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON=/bin/python excelbench calc -o results-2026-10-02/calc` (defaults: `fixtures/calc/financial_model.xlsx`, `fixtures/calc/expected_values.json`). diff --git a/results-2026-10-02/calc/aspose_cells_foss/financial_model.xlsx b/results-2026-10-02/calc/aspose_cells_foss/financial_model.xlsx index 1fdb3b8..10374b4 100644 Binary files a/results-2026-10-02/calc/aspose_cells_foss/financial_model.xlsx and b/results-2026-10-02/calc/aspose_cells_foss/financial_model.xlsx differ diff --git a/results-2026-10-02/calc/libreoffice/financial_model.xlsx b/results-2026-10-02/calc/libreoffice/financial_model.xlsx index 6cdd592..5f2715f 100644 Binary files a/results-2026-10-02/calc/libreoffice/financial_model.xlsx and b/results-2026-10-02/calc/libreoffice/financial_model.xlsx differ diff --git a/results-2026-10-02/calc/results.json b/results-2026-10-02/calc/results.json index c582600..6fa3ee8 100644 --- a/results-2026-10-02/calc/results.json +++ b/results-2026-10-02/calc/results.json @@ -55,73 +55,37 @@ } ], "reason": "FOSS FormulaEvaluator returned no value for 107 formulas: Schedule!B2, Schedule!C2, Schedule!D2, Schedule!E2, Schedule!F2", + "saved_matched": null, "status": "failed", - "total": 133 + "total": 133, + "version": "26.7.0" }, "libreoffice": { "matched": 133, "mismatched_cells": [], "reason": null, + "saved_matched": null, "status": "passed", - "total": 133 + "total": 133, + "version": "26.8.0.3 (build bce0998afefdbc355585ca324285661a2170ba77)" }, "wolfxl": { - "matched": 0, - "mismatched_cells": [ - { - "actual": null, - "cell": "Schedule!B10", - "expected": 0.0 - }, - { - "actual": null, - "cell": "Schedule!B11", - "expected": -308872.0 - }, - { - "actual": null, - "cell": "Schedule!B2", - "expected": 100000.0 - }, - { - "actual": null, - "cell": "Schedule!B3", - "expected": 38000.0 - }, - { - "actual": null, - "cell": "Schedule!B4", - "expected": 62000.0 - }, - { - "actual": null, - "cell": "Schedule!B5", - "expected": 12000.0 - }, - { - "actual": null, - "cell": "Schedule!B6", - "expected": 488.0 - }, - { - "actual": null, - "cell": "Schedule!B7", - "expected": 719.0 - }, - { - "actual": null, - "cell": "Schedule!B8", - "expected": 400872.0 - }, - { - "actual": null, - "cell": "Schedule!B9", - "expected": -300872.0 - } - ], - "reason": "133/133 formula values wrong or missing (first mismatch batch: 10 shown, 10 missing, 0 wrong)", - "status": "failed", - "total": 133 + "matched": 133, + "mismatched_cells": [], + "reason": null, + "saved_matched": 0, + "status": "passed", + "total": 133, + "version": "2.0.7" + }, + "wolfxl-commercial": { + "matched": 133, + "mismatched_cells": [], + "reason": null, + "saved_matched": 133, + "status": "passed", + "total": 133, + "version": "2.3.0" }, "zavora": { "matched": 0, @@ -178,10 +142,16 @@ } ], "reason": "133/133 formula values wrong or missing (first mismatch batch: 10 shown, 10 missing, 0 wrong)", + "saved_matched": null, "status": "failed", - "total": 133 + "total": 133, + "version": null } }, "fixture": "fixtures/calc/financial_model.xlsx", + "metadata": { + "platform": "Darwin-arm64", + "run_date": "2026-10-02T11:37:18.885621+00:00" + }, "oracle": "libreoffice/LibreOffice 26.8.0.3 bce0998afefdbc355585ca324285661a2170ba77" } diff --git a/results-2026-10-02/calc/wolfxl-commercial/financial_model.xlsx b/results-2026-10-02/calc/wolfxl-commercial/financial_model.xlsx new file mode 100644 index 0000000..8cfa60e Binary files /dev/null and b/results-2026-10-02/calc/wolfxl-commercial/financial_model.xlsx differ diff --git a/src/excelbench/harness/calc.py b/src/excelbench/harness/calc.py index 69250e6..0be64f9 100644 --- a/src/excelbench/harness/calc.py +++ b/src/excelbench/harness/calc.py @@ -2,12 +2,16 @@ from __future__ import annotations +import importlib.metadata import json import math +import os +import platform import shutil import subprocess import tempfile from collections.abc import Mapping +from datetime import UTC, datetime from pathlib import Path from typing import Any, Protocol @@ -20,9 +24,7 @@ ) from excelbench.results.calc_renderer import render_calc_results -SOFFICE_PATH = Path( - shutil.which("soffice") or "/opt/homebrew/bin/soffice" -) +SOFFICE_PATH = Path(shutil.which("soffice") or "/opt/homebrew/bin/soffice") class CalcEngine(Protocol): @@ -36,6 +38,40 @@ def available(self) -> bool: def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: """Calculate a workbook and return its formula-cell values.""" + def version(self) -> str | None: + """Return the engine version that produced the results, if known.""" + + +def _package_version(distribution: str) -> str | None: + """Return an installed package's version, naming any non-registry install. + + A local wheel or editable checkout can carry a release's version string + while running different code, so a published result must cite the + install source as part of the version. + """ + try: + dist = importlib.metadata.distribution(distribution) + except importlib.metadata.PackageNotFoundError: + return None + return _describe_install(dist.version, dist.read_text("direct_url.json")) + + +def _describe_install(version: str, direct_url: str | None) -> str: + """Label a version with its install source from PEP 610 ``direct_url.json``.""" + if not direct_url: + return version + try: + source = json.loads(direct_url) + except json.JSONDecodeError: + return f"{version} (unknown install source)" + vcs_info = source.get("vcs_info") + if isinstance(vcs_info, dict): + return f"{version} (vcs {str(vcs_info.get('commit_id', ''))[:12]})" + dir_info = source.get("dir_info") + if isinstance(dir_info, dict) and dir_info.get("editable"): + return f"{version} (local editable build)" + return f"{version} (local build)" + def formula_cells(input_path: Path) -> list[str]: """Return workbook-qualified references for every formula cell in a workbook.""" @@ -69,6 +105,20 @@ def libreoffice_version() -> str | None: return version or None +def _adapter_libreoffice_version(banner: str | None) -> str | None: + """Turn ``LibreOffice 26.8.0.3 `` into ``26.8.0.3 (build )``. + + The LibreOffice adapter reports that form, and one engine has one version + across every lane of a snapshot. + """ + if banner is None: + return None + parts = banner.split() + if len(parts) == 3 and parts[0] == "LibreOffice": + return f"{parts[1]} (build {parts[2]})" + return banner + + def recalculate_with_libreoffice(input_path: Path, output_path: Path) -> str | None: """Recalculate ``input_path`` through an isolated headless LibreOffice profile. @@ -162,19 +212,124 @@ def available(self) -> bool: return False return True + def version(self) -> str | None: + """Return the installed WolfXL version and install source.""" + return _package_version("wolfxl") + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: - """Calculate with WolfXL, save the workbook, and read cached results.""" + """Score the values ``calculate()`` returns; record what ``save()`` persisted. + + WolfXL's calculation API returns its results directly. Whether a save + writes them into the file as cached values depends on the build, so the + saved values are reported separately instead of replacing the score. + """ try: import wolfxl output_path.parent.mkdir(parents=True, exist_ok=True) references = formula_cells(input_path) workbook = wolfxl.load_workbook(input_path) - workbook.calculate() + calculated = workbook.calculate() workbook.save(output_path) return { "status": "passed", - "values": _read_values(output_path, references, reader="wolfxl"), + "values": { + reference: calculated.get(reference) for reference in references + }, + "saved_values": _read_values(output_path, references, reader="wolfxl"), + "reason": None, + } + except Exception as exc: # Engine errors are data for the comparison report. + return { + "status": "failed", + "values": {}, + "reason": f"{type(exc).__name__}: {exc}", + } + + +WOLFXL_COMMERCIAL_PYTHON_ENV = "EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON" + +_WOLFXL_SUBPROCESS_SCRIPT = """ +import importlib.metadata, json, sys +dist = importlib.metadata.distribution("wolfxl") +report = {"version": dist.version, "direct_url": dist.read_text("direct_url.json")} +if len(sys.argv) == 3: + import wolfxl + references = json.load(sys.stdin) + workbook = wolfxl.load_workbook(sys.argv[1]) + calculated = workbook.calculate() + workbook.save(sys.argv[2]) + report["values"] = {reference: calculated.get(reference) for reference in references} +json.dump(report, sys.stdout, default=str) +""" + + +class WolfXLCommercialCalcEngine: + """WolfXL Commercial, run in the interpreter named by ``EXCELBENCH_WOLFXL_COMMERCIAL_PYTHON``. + + Commercial and Community both install as ``wolfxl``, so the Commercial build + runs in its own environment and reports results over JSON. + """ + + name = "wolfxl-commercial" + + def _python(self) -> str | None: + python = os.environ.get(WOLFXL_COMMERCIAL_PYTHON_ENV) + return python if python and Path(python).is_file() else None + + def _run(self, *args: str, stdin: str = "") -> dict[str, Any]: + python = self._python() + if python is None: + raise RuntimeError( + f"{WOLFXL_COMMERCIAL_PYTHON_ENV} does not name an interpreter" + ) + completed = subprocess.run( + [python, "-c", _WOLFXL_SUBPROCESS_SCRIPT, *args], + input=stdin, + capture_output=True, + text=True, + check=False, + timeout=600, + ) + if completed.returncode != 0: + detail = completed.stderr.strip().splitlines()[-1:] or ["no diagnostic"] + raise RuntimeError(detail[0]) + report = json.loads(completed.stdout) + if not isinstance(report, dict): + raise RuntimeError("helper returned a non-object report") + return report + + def available(self) -> bool: + """Return whether a Commercial interpreter is configured.""" + return self._python() is not None + + def version(self) -> str | None: + """Return the Commercial interpreter's WolfXL version and install source.""" + if not self.available(): + return None + try: + report = self._run() + except (RuntimeError, OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return None + return _describe_install(str(report["version"]), report.get("direct_url")) + + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: + """Score the values ``calculate()`` returns; record what ``save()`` persisted.""" + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + references = formula_cells(input_path) + report = self._run( + str(input_path), str(output_path), stdin=json.dumps(references) + ) + values = report.get("values") + if not isinstance(values, dict): + raise RuntimeError("helper report omitted values") + return { + "status": "passed", + "values": values, + "saved_values": _read_values( + output_path, references, reader="openpyxl" + ), "reason": None, } except Exception as exc: # Engine errors are data for the comparison report. @@ -194,6 +349,10 @@ def available(self) -> bool: """Return whether the configured LibreOffice binary is available.""" return libreoffice_version() is not None + def version(self) -> str | None: + """Return the LibreOffice version in the LibreOffice adapter's format.""" + return _adapter_libreoffice_version(libreoffice_version()) + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: """Calculate with LibreOffice and read cached results from its output.""" if not self.available(): @@ -235,6 +394,10 @@ def available(self) -> bool: return False return True + def version(self) -> str | None: + """Return the installed Aspose.Cells FOSS version and install source.""" + return _package_version("aspose-cells-foss") + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: """Evaluate every formula with the FOSS evaluator and save its output.""" try: @@ -286,6 +449,10 @@ def available(self) -> bool: """Return whether the catalogued Zavora helper can be launched.""" return external_oracle_catalog(_repository_root())["zavora"].is_available() + def version(self) -> str | None: + """Zavora's helper protocol does not report a version.""" + return None + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: """Delegate calculation to Zavora's JSON external-oracle protocol.""" tool = external_oracle_catalog(_repository_root())["zavora"] @@ -365,6 +532,7 @@ def run_calc_suite( "total": len(expected_cells), "mismatched_cells": [], "reason": f"{engine.name} is unavailable", + "version": None, } continue try: @@ -397,15 +565,27 @@ def run_calc_suite( else: status = str(engine_result["status"]) reason = engine_result.get("reason") + saved_values = engine_result.get("saved_values") + saved_matched = ( + _compare_expected_values(expected_cells, saved_values)[0] + if isinstance(saved_values, Mapping) + else None + ) engine_results[engine.name] = { "status": status, "matched": matched, "total": total, + "saved_matched": saved_matched, "mismatched_cells": mismatches, "reason": reason, + "version": _engine_version(engine), } results = { + "metadata": { + "run_date": datetime.now(UTC).isoformat(), + "platform": f"{platform.system()}-{platform.machine()}", + }, "fixture": str(fixture), "oracle": expected_data.get("oracle"), "engines": engine_results, @@ -414,6 +594,14 @@ def run_calc_suite( return results +def _engine_version(engine: CalcEngine) -> str | None: + """Return an engine's version; a failing probe records none instead of aborting the suite.""" + try: + return engine.version() + except Exception: # Versions are provenance, never a reason to drop results. + return None + + def _compare_expected_values( expected_cells: Mapping[str, Any], actual_values: Mapping[str, Any] ) -> tuple[int, list[dict[str, Any]]]: @@ -436,6 +624,7 @@ def _compare_expected_values( def _engines() -> tuple[CalcEngine, ...]: return ( WolfXLCalcEngine(), + WolfXLCommercialCalcEngine(), LibreOfficeCalcEngine(), AsposeCellsFossCalcEngine(), ZavoraCalcEngine(), diff --git a/src/excelbench/results/calc_renderer.py b/src/excelbench/results/calc_renderer.py index 5e55b88..3113b8d 100644 --- a/src/excelbench/results/calc_renderer.py +++ b/src/excelbench/results/calc_renderer.py @@ -24,26 +24,35 @@ def _render_markdown(results: dict[str, Any]) -> str: f"Oracle: {results.get('oracle') or 'unknown'}", "", "Formula values are compared with absolute tolerance 1e-6 or relative tolerance 1e-9 " - "for numbers; strings and booleans compare exactly.", + "for numbers; strings and booleans compare exactly. Saved to file counts the " + "matching values a save writes into the workbook, for engines that return " + "results through an API.", "", - "| Engine | Status | Matched/total | First mismatches |", - "| --- | --- | --- | --- |", + "| Engine | Version | Status | Matched/total | Saved to file | First mismatches |", + "| --- | --- | --- | --- | --- | --- |", ] if not isinstance(engines, dict): engines = {} + reasons: list[str] = [] for name, result in engines.items(): if not isinstance(result, dict): continue mismatches = result.get("mismatched_cells", []) mismatch_text = _mismatch_text(mismatches) + saved = result.get("saved_matched") + saved_text = "—" if saved is None else f"{saved}/{result.get('total', 0)}" lines.append( "| " - f"{name} | {result.get('status', 'unknown')} | " - f"{result.get('matched', 0)}/{result.get('total', 0)} | {mismatch_text} |" + f"{name} | {result.get('version') or 'unknown'} | " + f"{result.get('status', 'unknown')} | " + f"{result.get('matched', 0)}/{result.get('total', 0)} | {saved_text} | " + f"{mismatch_text} |" ) reason = result.get("reason") if reason: - lines.extend(["", f"{name}: {reason}"]) + reasons.append(f"{name}: {reason}") + for reason in reasons: + lines.extend(["", reason]) lines.append("") return "\n".join(lines) diff --git a/tests/test_calc_suite.py b/tests/test_calc_suite.py index 163d1b6..959ae1e 100644 --- a/tests/test_calc_suite.py +++ b/tests/test_calc_suite.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import sys from collections.abc import Callable from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path @@ -11,6 +12,7 @@ import pytest +from excelbench.harness import calc from excelbench.harness.calc import ( SOFFICE_PATH, AsposeCellsFossCalcEngine, @@ -59,10 +61,7 @@ def test_expected_values_cover_every_formula_cell() -> None: assert set(expected["cells"]) == set(formula_cells(FIXTURE)) assert 80 <= len(expected["cells"]) <= 140 - assert all( - cell["type"] in {"number", "string", "bool"} - for cell in expected["cells"].values() - ) + assert all(cell["type"] in {"number", "string", "bool"} for cell in expected["cells"].values()) def test_value_comparison_respects_tolerance_and_scalar_types() -> None: @@ -75,6 +74,35 @@ def test_value_comparison_respects_tolerance_and_scalar_types() -> None: assert not values_match(True, 1) +@pytest.mark.parametrize( + ("direct_url", "expected"), + [ + (None, "2.1.0"), + ( + '{"url": "file:///build/wolfxl-2.1.0.whl", "archive_info": {}}', + "2.1.0 (local build)", + ), + ( + '{"url": "file:///src/wolfxl", "dir_info": {"editable": true}}', + "2.1.0 (local editable build)", + ), + ( + ( + '{"url": "https://example.invalid/wolfxl.git", ' + '"vcs_info": {"vcs": "git", "commit_id": "1a9ad6700abcdef0123"}}' + ), + "2.1.0 (vcs 1a9ad6700abc)", + ), + ], +) +def test_package_version_names_non_registry_installs(direct_url: str | None, expected: str) -> None: + """A local build must not be reported as the registry release it shares a version with.""" + version = calc._describe_install("2.1.0", direct_url) + + assert version == expected + assert "/" not in version # install paths and URLs never reach published results + + @requires_libreoffice def test_local_wolfxl_and_libreoffice_engines_calculate(tmp_path: Path) -> None: wolfxl_engine = WolfXLCalcEngine() @@ -86,14 +114,10 @@ def test_local_wolfxl_and_libreoffice_engines_calculate(tmp_path: Path) -> None: except (AttributeError, ValueError): major, minor = 0, 0 if (major, minor) >= (2, 1): - wolfxl_result = wolfxl_engine.calculate( - FIXTURE, tmp_path / "wolfxl.xlsx" - ) + wolfxl_result = wolfxl_engine.calculate(FIXTURE, tmp_path / "wolfxl.xlsx") assert wolfxl_result["status"] == "passed", wolfxl_result["reason"] - libreoffice_result = LibreOfficeCalcEngine().calculate( - FIXTURE, tmp_path / "libreoffice.xlsx" - ) + libreoffice_result = LibreOfficeCalcEngine().calculate(FIXTURE, tmp_path / "libreoffice.xlsx") assert libreoffice_result["status"] == "passed", libreoffice_result["reason"] @@ -104,3 +128,113 @@ def test_aspose_engine_reports_unsupported_formulas_without_raising( assert result["status"] in {"failed", "unavailable"} assert result["reason"] + + +class _ApiOnlyEngine: + """An engine whose API returns correct values but whose save writes none.""" + + name = "api-only" + + def available(self) -> bool: + return True + + def version(self) -> str | None: + return "1.0" + + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: + expected = json.loads(EXPECTED.read_text(encoding="utf-8"))["cells"] + return { + "status": "passed", + "values": {reference: cell["value"] for reference, cell in expected.items()}, + "saved_values": {}, + "reason": None, + } + + +def test_api_results_score_separately_from_saved_values( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(calc, "_engines", lambda: (_ApiOnlyEngine(),)) + + result = calc.run_calc_suite(FIXTURE, EXPECTED, tmp_path)["engines"]["api-only"] + + assert result["status"] == "passed" + assert result["matched"] == result["total"] + assert result["saved_matched"] == 0 + assert "| api-only | 1.0 | passed | 133/133 | 0/133 |" in (tmp_path / "README.md").read_text( + encoding="utf-8" + ) + + +def test_commercial_engine_matches_in_process_engine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The subprocess protocol reports what the same build reports in process.""" + pytest.importorskip("wolfxl") + engine = calc.WolfXLCommercialCalcEngine() + monkeypatch.delenv(calc.WOLFXL_COMMERCIAL_PYTHON_ENV, raising=False) + assert not engine.available() + + monkeypatch.setenv(calc.WOLFXL_COMMERCIAL_PYTHON_ENV, sys.executable) + in_process = WolfXLCalcEngine().calculate(FIXTURE, tmp_path / "in-process.xlsx") + via_subprocess = engine.calculate(FIXTURE, tmp_path / "subprocess.xlsx") + + assert engine.version() == calc._package_version("wolfxl") + assert via_subprocess["status"] == in_process["status"] == "passed" + assert via_subprocess["values"] == in_process["values"] + assert via_subprocess["saved_values"] == in_process["saved_values"] + + +def test_report_keeps_every_engine_row_in_the_table(tmp_path: Path) -> None: + """Engine notes print after the table, so a note never splits it.""" + from excelbench.results.calc_renderer import render_calc_results + + engine = {"status": "failed", "matched": 0, "total": 1, "mismatched_cells": []} + render_calc_results( + {"oracle": "o", "engines": {"a": {**engine, "reason": "a broke"}, "b": engine}}, + tmp_path, + ) + lines = (tmp_path / "README.md").read_text().splitlines() + rows = [i for i, line in enumerate(lines) if line.startswith(("| a |", "| b |"))] + assert rows == [rows[0], rows[0] + 1] + assert lines.index("a: a broke") > rows[-1] + + +@pytest.mark.parametrize( + ("banner", "expected"), + [ + ("LibreOffice 26.8.0.3 bce0998afefd", "26.8.0.3 (build bce0998afefd)"), + ("LibreOffice 7.6.4.1", "LibreOffice 7.6.4.1"), + (None, None), + ], +) +def test_libreoffice_version_matches_adapter_format( + banner: str | None, expected: str | None +) -> None: + """The calc engine and the LibreOffice adapter name the same build the same way.""" + assert calc._adapter_libreoffice_version(banner) == expected + + +def test_failing_version_probe_keeps_results( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A version probe that raises must not discard the engine's scored results.""" + + class _BrokenVersionEngine: + name = "broken-version" + + def available(self) -> bool: + return True + + def version(self) -> str | None: + raise RuntimeError("metadata unavailable") + + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: + return {"status": "passed", "values": {}, "reason": None} + + monkeypatch.setattr(calc, "_engines", lambda: (_BrokenVersionEngine(),)) + results = calc.run_calc_suite(FIXTURE, EXPECTED, tmp_path) + + assert results["engines"]["broken-version"]["version"] is None + assert (tmp_path / "results.json").exists() + diff --git a/tests/test_env_fallback_contracts.py b/tests/test_env_fallback_contracts.py index f378169..230bfa2 100644 --- a/tests/test_env_fallback_contracts.py +++ b/tests/test_env_fallback_contracts.py @@ -8,7 +8,9 @@ from __future__ import annotations import json +import platform import sys +from datetime import datetime from pathlib import Path from typing import Any @@ -55,6 +57,9 @@ def __init__( def available(self) -> bool: return self._available + def version(self) -> str | None: + return None + def calculate(self, input_path: Path, output_path: Path) -> dict[str, Any]: if self._raises is not None: raise self._raises @@ -133,6 +138,9 @@ def test_calc_suite_full_match_passes_and_renders( rendered = json.loads((tmp_path / "results.json").read_text()) assert set(rendered["engines"]) == {"good", "bad", "absent"} + # Importers date and place a calc-only snapshot from this metadata. + assert datetime.fromisoformat(rendered["metadata"]["run_date"]).tzinfo is not None + assert rendered["metadata"]["platform"] == f"{platform.system()}-{platform.machine()}" readme = (tmp_path / "README.md").read_text() assert "good" in readme and "unavailable" in readme.lower() diff --git a/uv.lock b/uv.lock index 5c55156..573beb3 100644 --- a/uv.lock +++ b/uv.lock @@ -2116,39 +2116,51 @@ wheels = [ [[package]] name = "wolfxl" -version = "2.0.1" +version = "2.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "defusedxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/ac/65759994e89287f651a9b5ba599e063e39d7db56b5fcbe709a0937b564f3/wolfxl-2.0.1.tar.gz", hash = "sha256:57d93932457efb0dde3971fdf796a1bec8c6014dc060638459e50e4d7736db13", size = 10530974, upload-time = "2026-08-18T07:30:52.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/28/cccc32d0a397621f8556938f02db97fea9eed47cbb6ace5879a7d2fc88e6/wolfxl-2.0.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a44a23217f3df45bf7817eba97be5cc190d03718aec0b955b9e30356bd59bcb5", size = 3327589, upload-time = "2026-08-18T07:30:03.937Z" }, - { url = "https://files.pythonhosted.org/packages/e0/89/7a89b3e6b2ea642eec0aff3b4e58c214e580d2580fb2e301b7819346a7b3/wolfxl-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d3da48a1b7ec2f1f7b62d2c0d92100bac37e197aace3c9635ab137a56a8e3ae", size = 3153645, upload-time = "2026-08-18T07:30:05.454Z" }, - { url = "https://files.pythonhosted.org/packages/81/bc/798df1baa83600aefdd8651ca36db0079456489050987889c734e7e56371/wolfxl-2.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ec77009031fb34c7802278cbc5ca5cd95f79f9b29a9da89ca41b8b509b9d3d87", size = 3232132, upload-time = "2026-08-18T07:30:07.079Z" }, - { url = "https://files.pythonhosted.org/packages/56/c4/5e03becc03814e56d8a765ee51f4a0aeb87c68333c4d9c03bc3ba16774b6/wolfxl-2.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6996b9e0d94ba25ed07e5a968227005fe5ef418c6c42060093a615d1fe72e309", size = 3364313, upload-time = "2026-08-18T07:30:08.602Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9d/192ff24dd4e035d60a039eaf9af732851174057b430dd731457100bbf7c7/wolfxl-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:717126da60bf77b41b6fe1f2302fbedb9e3543fee5a951beef2a8d07e9f48b43", size = 3374507, upload-time = "2026-08-18T07:30:10.25Z" }, - { url = "https://files.pythonhosted.org/packages/79/c6/9f85c6d0c6f928d694ad2048c81faa394feefc05f0c2979cfc640612d404/wolfxl-2.0.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9461100bc0e979aa1e19b29e405914245221e591ba8628d26ad5b3f1fa38a542", size = 3323795, upload-time = "2026-08-18T07:30:12.026Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/faa95ecefd9d085b4ea1cfcb1ca1c4f3de20603a1da797c62f7d83fd11a3/wolfxl-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d08f2e23e151b9ef51a84cde8bf55f52fa7ad8c67c15b5d1d3c2c713aafa07ae", size = 3151192, upload-time = "2026-08-18T07:30:13.649Z" }, - { url = "https://files.pythonhosted.org/packages/3b/65/6f424e6fadd072ed60c8d644e1c785205e4616e5ab189751500b4aa8e5eb/wolfxl-2.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:128b7a15037a3072b9657a7333f2d5aad18a7aa3e6643856870fc34e42d7632e", size = 3230920, upload-time = "2026-08-18T07:30:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/26/52/a24e280a72561b694b107946bfe3371602dc3e7761542412954ee9dfdab1/wolfxl-2.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:85437f13b5fcb1e4fce5c5c3d21f71db3c18685efd10bcd61be24a1363917f53", size = 3366381, upload-time = "2026-08-18T07:30:17.194Z" }, - { url = "https://files.pythonhosted.org/packages/82/46/8cc977e25c4570494fe0fb436d81f1b6061d729f5f14d6eebe49dfb82e2f/wolfxl-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:807d89a4f73f069322e27750f02ee428d27b955ae471a7e420643e612040cf89", size = 3365195, upload-time = "2026-08-18T07:30:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/a5/19/be4cef90586d77b25f85f8e5a5db7aa7743091feea8be0b22fe78fc625cc/wolfxl-2.0.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9092422063ceb1291b958716ad796a37b2df69d19c694fcc57923f44874b3403", size = 3324502, upload-time = "2026-08-18T07:30:20.024Z" }, - { url = "https://files.pythonhosted.org/packages/e7/06/fa126fd43d458ecd83294130ba9a91fa345c7cd35a1f52a7543cd1e10c74/wolfxl-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be728fa4e04804a2c365a6cac4950fb7be010a26db44da39cabc504b32c27228", size = 3151284, upload-time = "2026-08-18T07:30:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/85/1a/d129b7d9881c200104eef501b4c0cdb8a44ca53160e2855803c9da3c67fa/wolfxl-2.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1763e329d994ac0309e1552bf5b19fa9377474130567691a9d4b957b49f51226", size = 3230311, upload-time = "2026-08-18T07:30:22.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/80/1493b3d0e670ccc27a9a82de593e18ba8a8856d09db094fbf27459111c04/wolfxl-2.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a2f790b2dcf8de66f063d3197827c86fcb57f3817a9408d7c1977b9d4745147c", size = 3365113, upload-time = "2026-08-18T07:30:24.464Z" }, - { url = "https://files.pythonhosted.org/packages/ea/17/f7096c60f62fc4d580facbd8cfcf35ed97ce38b04563166d3d54c1199efb/wolfxl-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:aa079bce1ea79d94bbb6b26cbced8a547449d7801d898d46d35a9eaa66378726", size = 3364744, upload-time = "2026-08-18T07:30:25.991Z" }, - { url = "https://files.pythonhosted.org/packages/e0/2e/6cb9ec837327433ddd2c9b9630127accf5656b70ae64d2bb41d96d06700a/wolfxl-2.0.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dbfde5bfc0450e2455099fcd8eadd80a2260ad378c6f8a98b4fcf33fc12e4d8a", size = 3323781, upload-time = "2026-08-18T07:30:27.373Z" }, - { url = "https://files.pythonhosted.org/packages/14/21/6348295559e4a8e77b27f9f4aecb4838b64fc1b186e5149df08247ad8021/wolfxl-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9fd37dddf9c765673533f8afd8a974a737f24d4fd7f9b6c23e05e1d66e989782", size = 3152454, upload-time = "2026-08-18T07:30:28.788Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e5/a37e045e01d30e5ccf90c752b08ede0d5b704dda6eef4bbc0700bc45df43/wolfxl-2.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:45007ed29f6dee8ad92bdf9f196efe3c9981ac6ddc54c5176e07de433c98f18f", size = 3231819, upload-time = "2026-08-18T07:30:30.224Z" }, - { url = "https://files.pythonhosted.org/packages/17/a9/b9f7320226704d2929ff219c71abe417703c743b6e74037592df6e860f0b/wolfxl-2.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9ce3124f7db927537fff0d8e61d362cc94313b8285702fbd7203f80a90e6518a", size = 3365061, upload-time = "2026-08-18T07:30:31.63Z" }, - { url = "https://files.pythonhosted.org/packages/70/c9/ca0c1d76b3387dd3e8708ecba63a974bfd3c255f7eaf42108ac37c326df7/wolfxl-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:069e1be537b44bf9d003c8fefa721537b1a5478232ec4248378319005aaeff69", size = 3365582, upload-time = "2026-08-18T07:30:33.241Z" }, - { url = "https://files.pythonhosted.org/packages/95/ac/2d95abaa2c00f865fdf60f837db827c011c18fa2cdcc4e2138954d2f2595/wolfxl-2.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3dc52a0ee9341b20e14f72f57e4e01e44887c1aad43a122380f4cce89739fab0", size = 3236416, upload-time = "2026-08-18T07:30:34.84Z" }, - { url = "https://files.pythonhosted.org/packages/f7/44/5c0e799b855da4f55553e61741fa44b89ed1da489a9bd29fa3fe6dba9ef5/wolfxl-2.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:41fb6ac734d80cbadf904be79ed3e23513a66422dd24026306eb2b6aba63ce40", size = 3375950, upload-time = "2026-08-18T07:30:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/81f4fdf0df823af92fdbd512ae52a653b2b56209c7647f7acb4b217c147f/wolfxl-2.0.1-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:64cf32215c985c13002752939009509ba1b4164904a5bdd7efc39992e7df9904", size = 3365063, upload-time = "2026-08-18T07:30:38.301Z" }, - { url = "https://files.pythonhosted.org/packages/2b/26/7ed7d4aec18e23353957b2237bc5e78e06230fc19dd59a519731b0350e7b/wolfxl-2.0.1-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:41daab6e511ec253d0f4a87ffb8d961d9b7fc2e2dde56c2b08633b745be567b9", size = 3375953, upload-time = "2026-08-18T07:30:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/de6c1919a57c72125b99332fdad3ded2666600ea6c0a40367a1b845c8d12/wolfxl-2.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:370ff2cfa41158a4a0979ca07843241c4a9805f42d9c1f1dae6463358716edb6", size = 3235719, upload-time = "2026-08-18T07:30:48.673Z" }, - { url = "https://files.pythonhosted.org/packages/37/21/79f1e3d0b6bda50b98e417e7b34a98dfcfd29b1720af85dc9eadb632f7f8/wolfxl-2.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:07bab6fd6baa29068039c1d87b519f3772ba1a1a3057e95195944051ff11e2bb", size = 3366936, upload-time = "2026-08-18T07:30:50.067Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d2/84/77d934a2bba108e20789a225f49816d80b8d1ac67e99889736bc52bc64df/wolfxl-2.0.7.tar.gz", hash = "sha256:81dcf9c4289ba8a8589276c4083ab15ce276b72fcc69b937ada6ddbf88ed2b2d", size = 10676689, upload-time = "2026-10-02T11:26:45.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/71/d5631a9ae51f2046eacaee248145b1bbd2f09e6894f077332d43a492b446/wolfxl-2.0.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3257e4376a23263caa01cf4b7225dcf47b68e3a8013eeaeaae2a0cdb60c0a8f5", size = 3358589, upload-time = "2026-10-02T11:25:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/18/f4/52daecc6f445f1a42751eac11b949a39ed7cee74a2607c04ee49103c6f93/wolfxl-2.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0145a81964921c5ac43e67f256bb984bc68847433177c1325f21a666cb239af2", size = 3176898, upload-time = "2026-10-02T11:25:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c5/d9cdb321004b559c4e2bedccc0b40337130a8920da7c6007a25d0a9d4205/wolfxl-2.0.7-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dce26ae7fa8eeb8d2340519d0079a59c1a4014a7d4b97d5ea2d8d6a05c86e30a", size = 3258162, upload-time = "2026-10-02T11:25:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a5/5f58368c7438691984706a14d484915bd28eba954085f525c35315375111/wolfxl-2.0.7-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f23ac0a6e70976df0b5aa6f35dabdcd5a98abdd016c5c96ea707866629724a7e", size = 3414749, upload-time = "2026-10-02T11:25:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/99/fb/a73fd5ea4f334c39a322dff6afc3dc9a25e5d8616657d584e090ff45557d/wolfxl-2.0.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f93643584bc3db53ce6635f95fe9999f7dc24f08f19fc80b17983a906f341b07", size = 3436714, upload-time = "2026-10-02T11:25:38.961Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/b9f21d526d356a3504fb60265a9457ea7fd1167c7e738e31b8917f1d590a/wolfxl-2.0.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a0ee7e1d46cbd2a4114588021b145f767062c409c22e3886076c950a810b819", size = 3622135, upload-time = "2026-10-02T11:25:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/57/83/e3504389e3756e41bf3eff40e6e57569a38dcd5f1fa890dec011b0ddb728/wolfxl-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:c2808201f262b9d0263d3e3d84703aa8533d282e6f9cfcc05dde61ccb2209d84", size = 3407715, upload-time = "2026-10-02T11:25:42.027Z" }, + { url = "https://files.pythonhosted.org/packages/fb/54/dc4230e5a8ba316becdc855b93c6cbc2ea0b025637ee2477d2d5e17ab85e/wolfxl-2.0.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8cfbf050772455add13540d8b662e6df74708c80dea55566b6357821ccbff0a1", size = 3355697, upload-time = "2026-10-02T11:25:43.482Z" }, + { url = "https://files.pythonhosted.org/packages/4f/eb/7f0b0d1e9d0f4dab8667a2a3caa0693a381efcdcbcf06b0ca661759161ee/wolfxl-2.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:215dd610ffc1e04547d2b14cb7d99f28618c1f1f8ea2f8c6e4ee0dc1675f9c8d", size = 3175139, upload-time = "2026-10-02T11:25:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d8/974022e9acedbb725c307f1b32830d660604def5947d2f3036de17201420/wolfxl-2.0.7-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:46965c014fce2e6c6f7cd06455e531a9966574ea9df277d9debd7db4bd8d416f", size = 3258979, upload-time = "2026-10-02T11:25:46.563Z" }, + { url = "https://files.pythonhosted.org/packages/13/d6/13d1888d665ba76e572e5b9d186dc23b9c4cd307a0f3c28883e062d6893b/wolfxl-2.0.7-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9115776601aeefaba286591407aff5f75f9daeddcab558ea37797ba1f52a69e5", size = 3415212, upload-time = "2026-10-02T11:25:48.183Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f6/75fb69ae498c5360be6afb65042e837298296728dc822468398655ccefe8/wolfxl-2.0.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df07937fe2d4f5a92b73af908c4bc21064e19a3f23512f62aa4476b90730184d", size = 3437971, upload-time = "2026-10-02T11:25:49.746Z" }, + { url = "https://files.pythonhosted.org/packages/44/d8/b27477a454cf6432a9341471663bf86b29b5a62153986950638e76980787/wolfxl-2.0.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c89e7594b80e22b41120aae60edc84837669da3121de01c5abcbc01bde6b16d9", size = 3623153, upload-time = "2026-10-02T11:25:51.562Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/3d33c90ac29c45f7ff57bd87a9e4921071d4620707cd941d1c4f4efee94c/wolfxl-2.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:4a435f0a30432a4ba8a9e2d291027d3c69994fef684ac739cf7a6c2d3cb64b32", size = 3398213, upload-time = "2026-10-02T11:25:53.089Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/df8f1d9d33371b1ac134a31f5f405659943b709d47f6f704c4fb46e43dca/wolfxl-2.0.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d271f7dbec86aaf50534b986dca00fe85064db2595c7f3a80665afb9b4a5cca2", size = 3356024, upload-time = "2026-10-02T11:25:54.615Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b5/a94ae1feb4bf83ef876862e933b58abd646f597dd5225ccb5d7fdfe6a200/wolfxl-2.0.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fde58aef079b8a7f44638c4a13a99374f0580d53b454b3fed32fd301794adcb1", size = 3174767, upload-time = "2026-10-02T11:25:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/1b9dd6d11d55af300f32d7a13cba19f251309af79aa4dc3947cbd9837873/wolfxl-2.0.7-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:69c80dd985101168aee0ef178a6df820c33f80b21a03cba7c1f4e3bbd5f7bf4b", size = 3258263, upload-time = "2026-10-02T11:25:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/a9/96/b3a02c583a0864a4c8725ed7a3ce588e72864c70898e49fba7a9e5decfab/wolfxl-2.0.7-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2032fe2aa4e2ee0f40be7a51c56d5a5bdb5cdcf2b911f16a8293b14a120ea909", size = 3414847, upload-time = "2026-10-02T11:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7d/a683bafa4ad58a25ed15033e299050d1fd631cad54a52de357a22860f206/wolfxl-2.0.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32cee13a6d5ead41159ca53f73ad17df792f66a83003738cf47cd0094bcb08f1", size = 3436789, upload-time = "2026-10-02T11:26:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/e1/af/f1fe2f3db37adce851b20b233fe2421463777a2ad1ab15c36592e31585b2/wolfxl-2.0.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:337d36f45c35f11921160a0c0fa9efbb51f2e6eae3675d881827f283dbbe7493", size = 3622065, upload-time = "2026-10-02T11:26:02.511Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/daa4dd93d93d5772a93df85a7a7ae5d66cf1215354f7065869c6ba8892ce/wolfxl-2.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:e4e675e7cbd65237ec6a156f245f4c3432f33423d9ae87dc146b96a03c5f96ee", size = 3397878, upload-time = "2026-10-02T11:26:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/25/19/13672e92136ab712c61906427e526767211b8cd2eebfedeeeb5aa7881095/wolfxl-2.0.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:33843a8b22b927b5663a20a21524a957a78d14245348888ea48d77b5536a747b", size = 3356294, upload-time = "2026-10-02T11:26:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/89/1a/c5ad53b139764d677fc27c86ef65bd102bbbf61e50e8ebd82ef64085cbb3/wolfxl-2.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14638b6f036bc5443de1adb9f04da7f82b6c80f67f92e779e3f3fb0713f46e13", size = 3175789, upload-time = "2026-10-02T11:26:07.092Z" }, + { url = "https://files.pythonhosted.org/packages/f6/57/c4b942b48e3a4a4327403b9568b485bc18b502cd164eaee004459b9bcfa9/wolfxl-2.0.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2b9c8dc2313c4c0a1728a10e60d857b272759cba7d0362ec563e40a60ba4bb1d", size = 3258597, upload-time = "2026-10-02T11:26:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/a7/96/781e295730883a1eeddd9cf2744ecf0cc1f5444e4a20d06bbc6715a296c3/wolfxl-2.0.7-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9aebe4ed869a843630ec1ab727c33a755625951b46f8fa109b92eb9331b36930", size = 3415589, upload-time = "2026-10-02T11:26:10.446Z" }, + { url = "https://files.pythonhosted.org/packages/30/a1/79c5f927a0443baffd65deb249547d9132fca8e033eedc1e531bc09c5cb7/wolfxl-2.0.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f62b92315f551c07f14a6105ecfdba863a95cc579f49a965e827c133ecc9ca3a", size = 3437955, upload-time = "2026-10-02T11:26:12.346Z" }, + { url = "https://files.pythonhosted.org/packages/f7/95/d7901b9129ee4183ab004c8375301ba5c2dc955af727f011875dc33aa37c/wolfxl-2.0.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d9138ee7f408c2bad07071dd7fc004f4c459713068a14795d06582c3b9b190a6", size = 3623116, upload-time = "2026-10-02T11:26:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/91/f2/3ae1ed71b2aa78966e99cc389bf66a9b2b63bd8e6efcfeb825e21e79ad46/wolfxl-2.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:5f0a3a2cc54444e5e6a2ae8f6f645c5f76a7152fe2143e634c9d5cd42394f52b", size = 3398893, upload-time = "2026-10-02T11:26:15.942Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/57b95957c9f41353d8912d8ecf0cb4fff3a0ad600620a028d19452c55b44/wolfxl-2.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c2398aa06f7cd516b35015826866dffcdbc67af2354769db950d7356fb73ba6b", size = 3261902, upload-time = "2026-10-02T11:26:17.385Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/7d2b4cdcc001de010341932271a87c1eb0bdf159a0f230864553000dcfbd/wolfxl-2.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3950a36d5f04ec67344d0b7525d8e9aa7d502d0db4322595b5d6220bfc0edeba", size = 3423981, upload-time = "2026-10-02T11:26:18.75Z" }, + { url = "https://files.pythonhosted.org/packages/2d/90/c147a55415c82d8cccbd15475f652686db268daaf14610ddb96992f9ea27/wolfxl-2.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a0a2c8694491fbce3e3e15c134207b60d0924e504fbce77a00515f051f4acc6", size = 3440849, upload-time = "2026-10-02T11:26:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c4/375917120815dead40e503e4975e0005ca606c13e144843a4015c6f63923/wolfxl-2.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6fc3ad76074d47d41c7c307063053d7aa7376b8c9b3183c2db447e250ed15962", size = 3632967, upload-time = "2026-10-02T11:26:21.954Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4b/b630c82a23f0684f964dcb4f8a0b20b5778ada65c0594c58a8d20b706328/wolfxl-2.0.7-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:3a01b9cb4d1f2aeb49acee21bd90f0054c4d79a8386b065e5f19902591e1daaa", size = 3415589, upload-time = "2026-10-02T11:26:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5f/67ba75004c4219a3067ff82c2b9a28b06b252580ca99f82d9398a393ce2a/wolfxl-2.0.7-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:2fa9cfc72581d6c4e213f04ddeb09522a356d3d9d7280948ee3d631bca8ce2ae", size = 3423983, upload-time = "2026-10-02T11:26:25.318Z" }, + { url = "https://files.pythonhosted.org/packages/a8/91/f9f11159d2a32dee1ec83bac596928d69c11357b2affd35a0b66d4264339/wolfxl-2.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:da65469f2ee2b0394f8b862991a484409d8df639c545781e7125b8a859e20bc4", size = 3260394, upload-time = "2026-10-02T11:26:38.36Z" }, + { url = "https://files.pythonhosted.org/packages/af/17/a19967e4c04b6135b907d5ae850c773789adb4bab50831f8514256668140/wolfxl-2.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9da4425911914319cbe87bf18c6d41b6630670ec148b1dbf386a25cff880dfb0", size = 3416768, upload-time = "2026-10-02T11:26:40.275Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/be662807f99a28e9e948d432f3b7c3cc5c71d17de7c6c7edf948d7392603/wolfxl-2.0.7-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ecc2355194ebd8f809e153d03edd18acfd31b09a78322f8188b506c73b4d7aa3", size = 3439058, upload-time = "2026-10-02T11:26:41.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/95/e1fa7430518297c28dbda927b47a73075360a83d02141fd6172b3e86d42c/wolfxl-2.0.7-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:fac3645e4c571180e92677538597edbddaf7e7353bf936b9c5111f5db13dc6ed", size = 3624147, upload-time = "2026-10-02T11:26:43.382Z" }, ] [[package]]