|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# Copyright 2026 The Logica Authors |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | +# you may not use this file except in compliance with the License. |
| 7 | +# You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | +# See the License for the specific language governing permissions and |
| 15 | +# limitations under the License. |
| 16 | + |
| 17 | +"""Compare C++ parser output vs python logica.py parse on integration tests. |
| 18 | +
|
| 19 | +Runs on all integration_tests/*.l files and compares JSON ASTs. |
| 20 | +Output format is intentionally similar to common/logica_test.py. |
| 21 | +
|
| 22 | +Usage: |
| 23 | + python3 parser_cpp/compare_integration_parse.py |
| 24 | + python3 parser_cpp/compare_integration_parse.py test_only=duckdb_json_test,closure_test |
| 25 | + python3 parser_cpp/compare_integration_parse.py strict_errors |
| 26 | + python3 parser_cpp/compare_integration_parse.py show_timings |
| 27 | +
|
| 28 | +Notes: |
| 29 | +- Assumes repo root as working directory. |
| 30 | +- Honors LOGICAPATH environment variable for both parsers. |
| 31 | +- Uses in-process parsing for both modes by temporarily setting LOGICA_PARSER. |
| 32 | +- By default, tests PASS if both parsers fail (same success/failure). Use strict_errors |
| 33 | + to require identical stderr on failing parses. |
| 34 | +""" |
| 35 | + |
| 36 | +from __future__ import annotations |
| 37 | + |
| 38 | +import contextlib |
| 39 | +import difflib |
| 40 | +import glob |
| 41 | +import json |
| 42 | +import os |
| 43 | +import sys |
| 44 | +import time |
| 45 | +import traceback |
| 46 | +from typing import Any, List, Tuple |
| 47 | + |
| 48 | + |
| 49 | +# Ensure repo root is importable even when running this script from a subdir. |
| 50 | +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) |
| 51 | +if REPO_ROOT not in sys.path: |
| 52 | + sys.path.insert(0, REPO_ROOT) |
| 53 | + |
| 54 | +# Allow running as a script from repo root. |
| 55 | +if __name__ == '__main__' and not __package__: |
| 56 | + from common import color |
| 57 | + from parser_py import parse |
| 58 | +else: |
| 59 | + from ..common import color |
| 60 | + from ..parser_py import parse |
| 61 | + |
| 62 | + |
| 63 | +RUN_ONLY: List[str] = [] |
| 64 | +STRICT_ERRORS = False |
| 65 | +SHOW_TIMINGS = False |
| 66 | + |
| 67 | + |
| 68 | +def _read_run_only(argv: List[str]) -> None: |
| 69 | + global RUN_ONLY |
| 70 | + global STRICT_ERRORS |
| 71 | + global SHOW_TIMINGS |
| 72 | + for a in argv: |
| 73 | + if a.startswith('test_only='): |
| 74 | + RUN_ONLY = [x for x in a.split('=', 1)[1].split(',') if x] |
| 75 | + if a == 'strict_errors': |
| 76 | + STRICT_ERRORS = True |
| 77 | + if a == 'show_timings': |
| 78 | + SHOW_TIMINGS = True |
| 79 | + |
| 80 | + |
| 81 | +def _print_header() -> None: |
| 82 | + print(color.Format('% 64s %s' % ('{warning}TEST{end}', '{warning}RESULT{end}'))) |
| 83 | + print(color.Format('% 64s %s' % ('{warning}----{end}', '{warning}------{end}'))) |
| 84 | + |
| 85 | +@contextlib.contextmanager |
| 86 | +def _temp_env(key: str, value: str) -> Any: |
| 87 | + old = os.environ.get(key) |
| 88 | + os.environ[key] = value |
| 89 | + try: |
| 90 | + yield |
| 91 | + finally: |
| 92 | + if old is None: |
| 93 | + os.environ.pop(key, None) |
| 94 | + else: |
| 95 | + os.environ[key] = old |
| 96 | + |
| 97 | + |
| 98 | +def _import_root_from_env() -> Any: |
| 99 | + import_root_env = os.environ.get('LOGICAPATH') |
| 100 | + if not import_root_env: |
| 101 | + return None |
| 102 | + roots = import_root_env.split(':') |
| 103 | + if len(roots) > 1: |
| 104 | + return roots |
| 105 | + return import_root_env |
| 106 | + |
| 107 | + |
| 108 | +def _format_parse_exception(e: BaseException) -> str: |
| 109 | + buf = [] |
| 110 | + if hasattr(e, 'ShowMessage') and callable(getattr(e, 'ShowMessage')): |
| 111 | + try: |
| 112 | + import io |
| 113 | + s = io.StringIO() |
| 114 | + e.ShowMessage(stream=s) # type: ignore[attr-defined] |
| 115 | + return s.getvalue() |
| 116 | + except Exception: # pylint: disable=broad-exception-caught |
| 117 | + pass |
| 118 | + buf.append(f"{type(e).__name__}: {e}\n") |
| 119 | + return ''.join(buf) |
| 120 | + |
| 121 | + |
| 122 | +def _parse_rules_timed(program_text: str, parser_mode: str) -> Tuple[int, str, Any, float]: |
| 123 | + """Returns (rc, stderr, rules_or_none, seconds).""" |
| 124 | + import_root = _import_root_from_env() |
| 125 | + t0 = time.perf_counter() |
| 126 | + with _temp_env('LOGICA_PARSER', parser_mode): |
| 127 | + try: |
| 128 | + rules = parse.ParseFile(program_text, import_root=import_root)['rule'] |
| 129 | + t1 = time.perf_counter() |
| 130 | + return 0, '', rules, (t1 - t0) |
| 131 | + except parse.ParsingException as e: |
| 132 | + t1 = time.perf_counter() |
| 133 | + return 1, _format_parse_exception(e), None, (t1 - t0) |
| 134 | + except BaseException as e: # noqa: BLE001 |
| 135 | + t1 = time.perf_counter() |
| 136 | + return 2, traceback.format_exc() or _format_parse_exception(e), None, (t1 - t0) |
| 137 | + |
| 138 | + |
| 139 | +def _canonical_dump(x: Any) -> str: |
| 140 | + # Match logica.py parse: sort_keys=True, indent=' ' (one space). |
| 141 | + return json.dumps(x, sort_keys=True, indent=' ') + "\n" |
| 142 | + |
| 143 | + |
| 144 | +def _diff(a: str, b: str, from_name: str, to_name: str, limit: int = 200) -> str: |
| 145 | + lines = list(difflib.unified_diff( |
| 146 | + a.splitlines(True), |
| 147 | + b.splitlines(True), |
| 148 | + fromfile=from_name, |
| 149 | + tofile=to_name, |
| 150 | + )) |
| 151 | + if len(lines) <= limit: |
| 152 | + return ''.join(lines) |
| 153 | + return ''.join(lines[:limit] + [f"\n... (diff truncated to {limit} lines)\n"]) |
| 154 | + |
| 155 | + |
| 156 | +def _percentile(sorted_values: List[float], p: float) -> float: |
| 157 | + if not sorted_values: |
| 158 | + return 0.0 |
| 159 | + if p <= 0.0: |
| 160 | + return sorted_values[0] |
| 161 | + if p >= 100.0: |
| 162 | + return sorted_values[-1] |
| 163 | + idx = (p / 100.0) * (len(sorted_values) - 1) |
| 164 | + lo = int(idx) |
| 165 | + hi = min(lo + 1, len(sorted_values) - 1) |
| 166 | + frac = idx - lo |
| 167 | + return sorted_values[lo] * (1.0 - frac) + sorted_values[hi] * frac |
| 168 | + |
| 169 | + |
| 170 | +def _print_timing_summary(ran: List[str], ran_files: List[str], py_times: List[float], cpp_times: List[float]) -> None: |
| 171 | + if not ran: |
| 172 | + return |
| 173 | + |
| 174 | + py_total = sum(py_times) |
| 175 | + cpp_total = sum(cpp_times) |
| 176 | + py_sorted = sorted(py_times) |
| 177 | + cpp_sorted = sorted(cpp_times) |
| 178 | + |
| 179 | + def fmt_s(x: float) -> str: |
| 180 | + return f"{x:.3f}s" |
| 181 | + |
| 182 | + def fmt_ms(x: float) -> str: |
| 183 | + return f"{x * 1000.0:.1f}ms" |
| 184 | + |
| 185 | + print(color.Format('\n{warning}Timing summary{end} (one run per file)')) |
| 186 | + print(f" Tests: {len(ran)}") |
| 187 | + print( |
| 188 | + " Python: total %s | avg %s | p50 %s | p95 %s" % ( |
| 189 | + fmt_s(py_total), |
| 190 | + fmt_ms(py_total / len(ran)), |
| 191 | + fmt_ms(_percentile(py_sorted, 50.0)), |
| 192 | + fmt_ms(_percentile(py_sorted, 95.0)), |
| 193 | + ) |
| 194 | + ) |
| 195 | + print( |
| 196 | + " C++: total %s | avg %s | p50 %s | p95 %s" % ( |
| 197 | + fmt_s(cpp_total), |
| 198 | + fmt_ms(cpp_total / len(ran)), |
| 199 | + fmt_ms(_percentile(cpp_sorted, 50.0)), |
| 200 | + fmt_ms(_percentile(cpp_sorted, 95.0)), |
| 201 | + ) |
| 202 | + ) |
| 203 | + if cpp_total > 0.0: |
| 204 | + print(f" Speedup (total): {py_total / cpp_total:.2f}x") |
| 205 | + |
| 206 | + by_py = sorted(zip(py_times, ran_files), reverse=True)[:5] |
| 207 | + by_cpp = sorted(zip(cpp_times, ran_files), reverse=True)[:5] |
| 208 | + slow_py = ', '.join([f"{fmt_ms(t)} {os.path.basename(f)}" for t, f in by_py]) |
| 209 | + slow_cpp = ', '.join([f"{fmt_ms(t)} {os.path.basename(f)}" for t, f in by_cpp]) |
| 210 | + print(f" Slowest Python: {slow_py}") |
| 211 | + print(f" Slowest C++: {slow_cpp}") |
| 212 | + |
| 213 | + |
| 214 | +def _compare_one(test_name: str, path: str) -> Tuple[bool, float, float]: |
| 215 | + if RUN_ONLY and test_name not in RUN_ONLY: |
| 216 | + return True, 0.0, 0.0 |
| 217 | + |
| 218 | + test_result = '{warning}RUNNING{end}' |
| 219 | + print(color.Format('% 50s %s' % (test_name, test_result))) |
| 220 | + |
| 221 | + program_text = open(path, 'r', encoding='utf-8').read() |
| 222 | + |
| 223 | + # Force both modes regardless of caller environment. |
| 224 | + py_rc, py_err, py_json, py_s = _parse_rules_timed(program_text, 'PY') |
| 225 | + cpp_rc, cpp_err, cpp_json, cpp_s = _parse_rules_timed(program_text, 'CPP') |
| 226 | + |
| 227 | + ok = True |
| 228 | + details = '' |
| 229 | + |
| 230 | + if py_rc == 0 and cpp_rc == 0: |
| 231 | + if py_json != cpp_json: |
| 232 | + ok = False |
| 233 | + a = _canonical_dump(py_json) |
| 234 | + b = _canonical_dump(cpp_json) |
| 235 | + details += _diff(a, b, f"python:{path}", f"cpp:{path}") |
| 236 | + elif py_rc != 0 and cpp_rc != 0: |
| 237 | + # Default: only require both to fail. Use strict_errors to compare outputs. |
| 238 | + if STRICT_ERRORS and py_err != cpp_err: |
| 239 | + ok = False |
| 240 | + details += "Both parsers failed, but error output differs.\n" |
| 241 | + details += _diff(py_err, cpp_err, f"python-stderr:{path}", f"cpp-stderr:{path}") |
| 242 | + else: |
| 243 | + ok = False |
| 244 | + if py_rc != 0: |
| 245 | + details += f"Python parser failed (rc={py_rc})\n{py_err}\n" |
| 246 | + if cpp_rc != 0: |
| 247 | + details += f"C++ parser failed (rc={cpp_rc})\n{cpp_err}\n" |
| 248 | + |
| 249 | + if ok: |
| 250 | + test_result = '{ok}PASSED{end}' |
| 251 | + else: |
| 252 | + test_result = '{error}FAILED{end}' |
| 253 | + |
| 254 | + line = '% 50s %s' % (test_name, test_result) |
| 255 | + if SHOW_TIMINGS: |
| 256 | + line += ' (py=%6.1fms, cpp=%6.1fms)' % (py_s * 1000.0, cpp_s * 1000.0) |
| 257 | + print('\033[F\033[K' + color.Format(line)) |
| 258 | + |
| 259 | + if not ok: |
| 260 | + # Keep failure output readable and similar to other test runners. |
| 261 | + print(details.rstrip() + "\n") |
| 262 | + |
| 263 | + return ok, py_s, cpp_s |
| 264 | + |
| 265 | + |
| 266 | +def main(argv: List[str]) -> int: |
| 267 | + _read_run_only(argv) |
| 268 | + _print_header() |
| 269 | + |
| 270 | + files = sorted(glob.glob('integration_tests/*.l')) |
| 271 | + if not files: |
| 272 | + print('No integration_tests/*.l files found.', file=sys.stderr) |
| 273 | + return 2 |
| 274 | + |
| 275 | + failed: List[str] = [] |
| 276 | + ran: List[str] = [] |
| 277 | + ran_files: List[str] = [] |
| 278 | + py_times: List[float] = [] |
| 279 | + cpp_times: List[float] = [] |
| 280 | + |
| 281 | + for path in files: |
| 282 | + test_name = os.path.splitext(os.path.basename(path))[0] |
| 283 | + if RUN_ONLY and test_name not in RUN_ONLY: |
| 284 | + continue |
| 285 | + ran.append(test_name) |
| 286 | + ran_files.append(path) |
| 287 | + ok, py_s, cpp_s = _compare_one(test_name, path) |
| 288 | + py_times.append(py_s) |
| 289 | + cpp_times.append(cpp_s) |
| 290 | + if not ok: |
| 291 | + failed.append(test_name) |
| 292 | + |
| 293 | + if failed: |
| 294 | + _print_timing_summary(ran, ran_files, py_times, cpp_times) |
| 295 | + print(color.Format('{error}FAILED{end}: %d tests' % len(failed))) |
| 296 | + print('Failed:', ', '.join(failed)) |
| 297 | + return 1 |
| 298 | + |
| 299 | + _print_timing_summary(ran, ran_files, py_times, cpp_times) |
| 300 | + print(color.Format('{ok}PASSED{end}: %d tests' % len(ran))) |
| 301 | + return 0 |
| 302 | + |
| 303 | + |
| 304 | +if __name__ == '__main__': |
| 305 | + raise SystemExit(main(sys.argv[1:])) |
0 commit comments