-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(preprod): Add odiff server wrapper and Dockerfile binary install #109380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06bdc3c
974e048
420a881
fa25780
3ca7a23
6f8e0b1
50bcd46
a87b6ab
9d76e77
8c386f8
ccf5df7
4b62d3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -228,6 +228,7 @@ | |
| "jest-fail-on-console": "3.3.1", | ||
| "jest-junit": "16.0.0", | ||
| "knip": "5.83.1", | ||
| "odiff-bin": "^4.3.2", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need the package.json change? This is only touching backend
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. right, i removed it initially as you remember, but as it turns out, for local development, this is the easiest way to have access to odiff |
||
| "postcss-styled-syntax": "0.7.0", | ||
| "react-refresh": "0.18.0", | ||
| "stylelint": "16.10.0", | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import platform | ||
| import select | ||
| import shutil | ||
| import subprocess | ||
| import threading | ||
| from pathlib import Path | ||
|
|
||
| from sentry.preprod.snapshots.image_diff.types import OdiffResponse | ||
| from sentry.utils import json | ||
|
|
||
| ODIFF_PLATFORM_SUFFIXES = { | ||
| ("arm64", "Darwin"): "macos-arm64", | ||
| ("aarch64", "Darwin"): "macos-arm64", | ||
| ("x86_64", "Darwin"): "macos-x64", | ||
| ("x86_64", "Linux"): "linux-x64", | ||
| ("aarch64", "Linux"): "linux-arm64", | ||
| ("arm64", "Linux"): "linux-arm64", | ||
| } | ||
|
|
||
|
|
||
| def _find_odiff_binary() -> str: | ||
| suffix = ODIFF_PLATFORM_SUFFIXES.get((platform.machine(), platform.system())) | ||
|
|
||
| current = Path(__file__).resolve() | ||
| for parent in current.parents: | ||
| if (parent / "pyproject.toml").exists(): | ||
| if suffix: | ||
| raw = parent / "node_modules" / "odiff-bin" / "raw_binaries" / f"odiff-{suffix}" | ||
| if raw.exists(): | ||
| return str(raw) | ||
| break | ||
|
|
||
| found = shutil.which("odiff") | ||
| if found: | ||
| return found | ||
| raise FileNotFoundError("odiff binary not found. Run 'pnpm install' to install odiff-bin.") | ||
|
|
||
|
|
||
| class OdiffServer: | ||
| def __init__(self) -> None: | ||
| self._process: subprocess.Popen[bytes] | None = None | ||
| self._request_id = 0 | ||
| self._lock = threading.Lock() | ||
|
|
||
| def __enter__(self) -> OdiffServer: | ||
| with self._lock: | ||
| if self._process is None: | ||
| self._start() | ||
| return self | ||
|
NicoHinderling marked this conversation as resolved.
|
||
|
|
||
| def __exit__(self, *args: object) -> None: | ||
| self.close() | ||
|
|
||
| def _read_response(self, line: bytes) -> OdiffResponse: | ||
| if not line: | ||
|
Check warning on line 57 in src/sentry/preprod/snapshots/image_diff/odiff.py
|
||
| raise RuntimeError("odiff server exited unexpectedly") | ||
| return OdiffResponse.parse_obj(json.loads(line)) | ||
|
|
||
| def _kill_process(self) -> None: | ||
| proc = self._process | ||
| self._process = None | ||
| if proc is None: | ||
| return | ||
| proc.kill() | ||
| try: | ||
| proc.wait(timeout=5) | ||
| except subprocess.TimeoutExpired: | ||
| pass | ||
|
|
||
| def _start(self) -> None: | ||
| binary = _find_odiff_binary() | ||
| proc = subprocess.Popen( | ||
| [binary, "--server"], | ||
| stdin=subprocess.PIPE, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.DEVNULL, | ||
| start_new_session=True, | ||
| ) | ||
|
NicoHinderling marked this conversation as resolved.
|
||
| try: | ||
| readable, _, _ = select.select([proc.stdout], [], [], 30) | ||
| if not readable: | ||
| raise RuntimeError("odiff server timed out waiting for ready message") | ||
| line = proc.stdout.readline() # type: ignore[union-attr] | ||
|
NicoHinderling marked this conversation as resolved.
|
||
| ready = json.loads(line) | ||
| if not ready.get("ready"): | ||
| raise RuntimeError("odiff server failed to start") | ||
| except BaseException: | ||
| proc.kill() | ||
| try: | ||
| proc.wait(timeout=5) | ||
| except subprocess.TimeoutExpired: | ||
| pass | ||
| raise | ||
| self._process = proc | ||
|
|
||
| def compare( | ||
| self, | ||
| base_path: str | Path, | ||
| compare_path: str | Path, | ||
| output_path: str | Path, | ||
| **options: object, | ||
| ) -> OdiffResponse: | ||
| with self._lock: | ||
| if self._process is None: | ||
| self._start() | ||
|
|
||
| process = self._process | ||
| if process is None or process.stdin is None or process.stdout is None: | ||
| raise RuntimeError("odiff server failed to initialize") | ||
| self._request_id += 1 | ||
| request: dict[str, object] = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be a bit cleaner to have this be a type so we don't use untyped dicts
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for this case, I think we can leave this request object as is |
||
| "requestId": self._request_id, | ||
| "base": str(base_path), | ||
| "compare": str(compare_path), | ||
| "output": str(output_path), | ||
| } | ||
| if options: | ||
| request["options"] = options | ||
|
|
||
| try: | ||
| process.stdin.write(json.dumps(request).encode() + b"\n") | ||
| process.stdin.flush() | ||
| except (BrokenPipeError, OSError) as e: | ||
| self._kill_process() | ||
| raise RuntimeError("odiff process died unexpectedly") from e | ||
|
|
||
| readable, _, _ = select.select([process.stdout], [], [], 30) | ||
| if not readable: | ||
| self._kill_process() | ||
| raise RuntimeError("odiff server timed out after 30s") | ||
| line = process.stdout.readline() | ||
|
sentry[bot] marked this conversation as resolved.
NicoHinderling marked this conversation as resolved.
|
||
| try: | ||
| response = self._read_response(line) | ||
| except RuntimeError: | ||
| self._kill_process() | ||
| raise | ||
|
NicoHinderling marked this conversation as resolved.
|
||
|
|
||
| if response.error: | ||
| raise RuntimeError(f"odiff error: {response.error}") | ||
|
|
||
| return response | ||
|
|
||
| def close(self) -> None: | ||
| with self._lock: | ||
| if not self._process: | ||
| return | ||
| proc = self._process | ||
| self._process = None | ||
|
|
||
| if proc.stdin: | ||
| try: | ||
| proc.stdin.close() | ||
| except OSError: | ||
| pass | ||
| try: | ||
| proc.wait(timeout=3) | ||
| return | ||
| except subprocess.TimeoutExpired: | ||
| pass | ||
| proc.terminate() | ||
| try: | ||
| proc.wait(timeout=5) | ||
| return | ||
| except subprocess.TimeoutExpired: | ||
| pass | ||
| proc.kill() | ||
| try: | ||
| proc.wait(timeout=5) | ||
| except subprocess.TimeoutExpired: | ||
| pass | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
|
|
||
| class DiffResult(BaseModel): | ||
| model_config = ConfigDict(frozen=True) | ||
|
|
||
| diff_mask_png: str | ||
| diff_score: float | ||
| changed_pixels: int | ||
| total_pixels: int | ||
| aligned_height: int | ||
| width: int | ||
| before_width: int | ||
| before_height: int | ||
| after_width: int | ||
| after_height: int | ||
|
|
||
|
|
||
| class OdiffResponse(BaseModel): | ||
| model_config = ConfigDict(frozen=True) | ||
|
|
||
| requestId: int | ||
| exitCode: int | ||
| result: str | ||
| diffCount: int | None = None | ||
| diffPercentage: float | None = None | ||
| error: str | None = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, why is this needed in knip? That's only a frontend check IIUC
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is needed because odiff is used for dev purposes and knip freaks out that we have a package that doesnt seem to be referenced in the frontend code