Skip to content

feat(preprod): Add odiff server wrapper and Dockerfile binary install#109380

Merged
NicoHinderling merged 12 commits intomasterfrom
create-compare-task-v1-part1
Feb 26, 2026
Merged

feat(preprod): Add odiff server wrapper and Dockerfile binary install#109380
NicoHinderling merged 12 commits intomasterfrom
create-compare-task-v1-part1

Conversation

@NicoHinderling
Copy link
Contributor

Summary

  • Add OdiffServer subprocess wrapper that communicates with odiff via JSON-over-stdin/stdout protocol
  • Add DiffResult dataclass for structured diff output
  • Install the odiff binary in the self-hosted Dockerfile (x64 + arm64, SHA256-verified)

Stack: 1/3 — next: image comparison library

&& apt-get update \
&& apt-get install -y --no-install-recommends $buildDeps \
&& uv sync --frozen --quiet --no-install-project \
&& case "$(dpkg --print-architecture)" in \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should move this into its own layer as it's separate from uv.lock, otherwise we'll do this every time a python dep changes

pretty sure this is all possible with docker primitives + multistage (to handle the docker arch for multiplat builds) at this point too; let me just commit this for you

Copy link
Contributor Author

@NicoHinderling NicoHinderling Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Contributor Author

NicoHinderling commented Feb 25, 2026

@NicoHinderling NicoHinderling force-pushed the create-compare-task-v1-part1 branch from ca48cee to 12f0802 Compare February 25, 2026 23:52
from dataclasses import dataclass


@dataclass(frozen=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why dataclass over pydantic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have too strong of an opinion but I'd lean to convention over a perfect performance implementation. I haven't personally seen dataclass used in the codebase, but I widely see pydantic models used, so for ease of use/devs to reuse best practices I'd lean towards using the more widely used convention, but up to you

Copy link
Contributor Author

@NicoHinderling NicoHinderling Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(i moved it to pydantic in this most recent iteration btw)

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] = {
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

compare_path: str | Path,
output_path: str | Path,
**options: object,
) -> dict[str, Any]:
Copy link
Member

@rbro112 rbro112 Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to type the response with a simple model to we're not using untyped dicts

def __exit__(self, *args: object) -> None:
self.close()

def _read_json(self, line: bytes) -> dict[str, Any]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We likely wouldn't need this if we just had a type for the response.

raise RuntimeError("odiff server timed out after 30s")
line = process.stdout.readline()
try:
response = self._read_json(line)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would become something like response = OdiffResponse.model_validate(line) if we used a pydantic model: https://docs.pydantic.dev/latest/concepts/json/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha, okay let me try that out

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept parse_obj(json.loads(line)) rather than model_validate_json since Sentry pins pydantic to v1 (pydantic>=1.10.26,<2 in pyproject.toml). model_validate_json is v2-only and doesn't exist at runtime or in mypy's pydantic plugin unfortunately

"jest-fail-on-console": "3.3.1",
"jest-junit": "16.0.0",
"knip": "5.83.1",
"odiff-bin": "^4.3.2",
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

'@babel/preset-react', // Still used in jest
'@babel/preset-typescript', // Still used in jest
'@emotion/babel-plugin', // Still used in jest
'odiff-bin', // raw binary consumed by Python backend, not a JS import
Copy link
Member

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

Copy link
Contributor Author

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

def __exit__(self, *args: object) -> None:
self.close()

def _read_json(self, line: bytes) -> dict[str, object]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to remove this entirely, it seems it's only used twice (_read_response and _start). _read_response is already typed and the json.loads should be fine to do inline. If it throws, that's a valid error we want to catch (and catching and rethrowing anyways?)

In _start I feel we should just inline json.loads(). Both errors here are just rethrown, so I don't see what catching and rethrowing is doing in this function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tried to incorporate this feedback. Removed _read_json entirely and inlined json.loads at both call sites. Kept parse_obj over model_validate_json since we pin pydantic to v1.

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

@NicoHinderling NicoHinderling merged commit afeb841 into master Feb 26, 2026
87 of 89 checks passed
@NicoHinderling NicoHinderling deleted the create-compare-task-v1-part1 branch February 26, 2026 19:26
NicoHinderling added a commit that referenced this pull request Feb 26, 2026
…109381)

## Summary
- Add `compare_images` and `compare_images_batch` functions using
dual-threshold detection (base + color-sensitive)
- Produces base64-encoded diff mask PNGs with pixel-level change data
- Unit tests covering identical, different, different-size, threshold
sensitivity, bytes input, and batch modes

**Stack**: 2/3 — depends on #109380, next: Celery task

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants