Skip to content

Commit 4a7cb3c

Browse files
codeSamuraiiCopilot
andcommitted
Fix stale image issue
Co-authored-by: Copilot <copilot@github.com>
1 parent f8443f6 commit 4a7cb3c

5 files changed

Lines changed: 332 additions & 6 deletions

File tree

examples/image_thumbnails.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from io import BytesIO
2020

21+
import uvicorn
2122
from fastapi import FastAPI, File, Form, UploadFile
2223
from fastapi.responses import Response
2324
from PIL import Image, ImageOps
@@ -73,3 +74,7 @@ async def make_thumbnail(
7374
blob = await file.read()
7475
jpeg = await thumbnail.run(blob, size=size)
7576
return Response(content=jpeg, media_type="image/jpeg")
77+
78+
79+
if __name__ == "__main__":
80+
uvicorn.run(app, host="0.0.0.0", port=8080)

examples/pdf_report.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from io import BytesIO
2626

27+
import uvicorn
2728
from fastapi import FastAPI
2829
from fastapi.responses import Response
2930
from pydantic import BaseModel
@@ -67,8 +68,11 @@ def render_report(title: str, rows: list[list[str | float]]) -> bytes:
6768
# --- FastAPI app ----------------------------------------------------------
6869

6970
class ReportRequest(BaseModel):
70-
title: str
71-
rows: list[list[str | float]]
71+
title: str = "Example Report"
72+
rows: list[list[str | float]] = [
73+
["Revenue", 120000],
74+
["Costs", 80000],
75+
]
7276

7377

7478
app = FastAPI(title="pyfuse PDF service")
@@ -84,7 +88,12 @@ async def _shutdown() -> None:
8488
await pyfuse.disconnect()
8589

8690

87-
@app.post("/reports")
88-
async def make_report(req: ReportRequest) -> Response:
91+
@app.get("/reports")
92+
async def make_report() -> Response:
93+
req = ReportRequest()
8994
pdf = await render_report.run(req.title, req.rows)
9095
return Response(content=pdf, media_type="application/pdf")
96+
97+
98+
if __name__ == "__main__":
99+
uvicorn.run(app, host="0.0.0.0", port=8080)

pyfuse/core/task.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,109 @@
11
"""Task dataclass: serializable envelope bundling a graph with arguments."""
22

3+
import base64
4+
import datetime as _dt
35
import json
6+
import pathlib
47
import uuid
8+
from decimal import Decimal
59
from typing import Any, Self
610
from dataclasses import field, dataclass
711

812
from pyfuse.core.errors import SignatureError
913
from pyfuse.core.signing import verify_signature, compute_signature
1014

1115
_OBJECT_SENTINEL = "__pyfuse_obj__"
16+
_BYTES_SENTINEL = "__pyfuse_bytes__"
17+
_BUILTIN_SENTINEL = "__pyfuse_builtin__"
18+
19+
20+
def _encode_builtin(o: object) -> dict[str, Any] | None:
21+
"""Encode common stdlib types to a JSON-safe sentinel.
22+
23+
Returns ``None`` if *o* is not a recognised builtin -- the caller
24+
falls back to object-state serialization.
25+
"""
26+
# datetime is a subclass of date, so check it first.
27+
if isinstance(o, _dt.datetime):
28+
return {"type": "datetime", "value": o.isoformat()}
29+
if isinstance(o, _dt.date):
30+
return {"type": "date", "value": o.isoformat()}
31+
if isinstance(o, _dt.time):
32+
return {"type": "time", "value": o.isoformat()}
33+
if isinstance(o, _dt.timedelta):
34+
return {"type": "timedelta", "value": o.total_seconds()}
35+
if isinstance(o, Decimal):
36+
return {"type": "decimal", "value": str(o)}
37+
if isinstance(o, uuid.UUID):
38+
return {"type": "uuid", "value": o.hex}
39+
if isinstance(o, complex):
40+
return {"type": "complex", "value": [o.real, o.imag]}
41+
if isinstance(o, (set, frozenset)):
42+
kind = "frozenset" if isinstance(o, frozenset) else "set"
43+
return {"type": kind, "value": list(o)}
44+
if isinstance(o, pathlib.PurePath):
45+
return {"type": "path", "value": str(o), "cls": type(o).__name__}
46+
return None
47+
48+
49+
_PATH_CLASSES: dict[str, type[pathlib.PurePath]] = {
50+
"PurePath": pathlib.PurePath,
51+
"PurePosixPath": pathlib.PurePosixPath,
52+
"PureWindowsPath": pathlib.PureWindowsPath,
53+
"Path": pathlib.Path,
54+
"PosixPath": pathlib.PurePosixPath,
55+
"WindowsPath": pathlib.PureWindowsPath,
56+
}
57+
58+
59+
def _decode_builtin(info: dict[str, Any], namespace: dict[str, Any]) -> Any:
60+
"""Reverse :func:`_encode_builtin`."""
61+
kind = info.get("type")
62+
raw = info.get("value")
63+
if kind == "datetime":
64+
return _dt.datetime.fromisoformat(raw)
65+
if kind == "date":
66+
return _dt.date.fromisoformat(raw)
67+
if kind == "time":
68+
return _dt.time.fromisoformat(raw)
69+
if kind == "timedelta":
70+
return _dt.timedelta(seconds=raw)
71+
if kind == "decimal":
72+
return Decimal(raw)
73+
if kind == "uuid":
74+
return uuid.UUID(hex=raw)
75+
if kind == "complex":
76+
return complex(raw[0], raw[1])
77+
if kind == "set":
78+
return {_resolve(v, namespace) for v in raw}
79+
if kind == "frozenset":
80+
return frozenset(_resolve(v, namespace) for v in raw)
81+
if kind == "path":
82+
# Try to honour the original class; fall back to a sensible
83+
# OS-portable default if the concrete subclass cannot be
84+
# instantiated on this platform.
85+
cls = _PATH_CLASSES.get(info.get("cls", ""), pathlib.PurePath)
86+
try:
87+
return cls(raw)
88+
except (NotImplementedError, TypeError):
89+
return pathlib.PurePath(raw)
90+
raise ValueError(f"Unknown builtin sentinel type: {kind!r}")
1291

1392

1493
class _TaskEncoder(json.JSONEncoder):
1594
"""JSON encoder that serializes arbitrary objects via class name + __dict__."""
1695

1796
def default(self, o: object) -> Any:
97+
if isinstance(o, (bytes, bytearray)):
98+
return {
99+
_BYTES_SENTINEL: {
100+
"data": base64.b64encode(bytes(o)).decode("ascii"),
101+
"type": type(o).__name__,
102+
}
103+
}
104+
builtin = _encode_builtin(o)
105+
if builtin is not None:
106+
return {_BUILTIN_SENTINEL: builtin}
18107
if hasattr(o, "__dict__"):
19108
state = o.__dict__
20109
elif hasattr(type(o), "__slots__"):
@@ -62,6 +151,12 @@ def _resolve(value: Any, namespace: dict[str, Any]) -> Any:
62151
return value
63152
if len(value) == 1 and _OBJECT_SENTINEL in value:
64153
return _reconstruct_object(value[_OBJECT_SENTINEL], namespace)
154+
if len(value) == 1 and _BYTES_SENTINEL in value:
155+
info = value[_BYTES_SENTINEL]
156+
raw = base64.b64decode(info["data"])
157+
return bytearray(raw) if info.get("type") == "bytearray" else raw
158+
if len(value) == 1 and _BUILTIN_SENTINEL in value:
159+
return _decode_builtin(value[_BUILTIN_SENTINEL], namespace)
65160
return {k: _resolve(v, namespace) for k, v in value.items()}
66161

67162

pyfuse/worker/sandbox/docker.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import shutil
2121
import asyncio
22+
import hashlib
2223
import logging
2324
import contextlib
2425
from typing import Any
@@ -32,7 +33,28 @@
3233
logger = logging.getLogger(__name__)
3334

3435
_DOCKERFILE_DIR = Path(__file__).resolve().parent # contains Dockerfile + guest_agent.py
35-
_DEFAULT_IMAGE = "pyfuse-sandbox"
36+
_IMAGE_ASSETS = ("Dockerfile", "guest_agent.py", "_protocol.py")
37+
38+
39+
def _assets_digest() -> str:
40+
"""Short hash of the files baked into the sandbox image.
41+
42+
Used as the default image tag so a code change in the guest agent
43+
or Dockerfile produces a fresh image instead of silently reusing a
44+
stale one whose ``guest_agent.py`` no longer matches the host.
45+
"""
46+
h = hashlib.sha256()
47+
for name in _IMAGE_ASSETS:
48+
path = _DOCKERFILE_DIR / name
49+
if path.exists():
50+
h.update(name.encode())
51+
h.update(b"\0")
52+
h.update(path.read_bytes())
53+
h.update(b"\0")
54+
return h.hexdigest()[:12]
55+
56+
57+
_DEFAULT_IMAGE = f"pyfuse-sandbox:{_assets_digest()}"
3658
_DEFAULT_CONTAINER = "pyfuse-sandbox"
3759

3860

@@ -156,6 +178,16 @@ async def start(self) -> None:
156178
await _build_image(self.image)
157179

158180
container = self.container_name
181+
if await _container_exists(container):
182+
current_image = await _container_image(container)
183+
if current_image != self.image:
184+
logger.info(
185+
"Container '%s' was built from a different image (%s); "
186+
"recreating from '%s'",
187+
container, current_image or "<unknown>", self.image,
188+
)
189+
await _docker_wait("rm", "-f", container)
190+
159191
if not await _container_running(container):
160192
if await _container_exists(container):
161193
await _docker_wait("rm", "-f", container)
@@ -371,6 +403,17 @@ async def _container_running(name: str) -> bool:
371403
return rc == 0 and stdout.strip().lower() == "true"
372404

373405

406+
async def _container_image(name: str) -> str | None:
407+
"""Return the image (with tag) the container was created from, or None."""
408+
rc, stdout, _ = await _docker_wait(
409+
"inspect", "-f", "{{.Config.Image}}", name,
410+
)
411+
if rc != 0:
412+
return None
413+
image = stdout.strip()
414+
return image or None
415+
416+
374417
async def _mapped_port(container: str, guest_port: int) -> int:
375418
rc, stdout, stderr = await _docker_wait("port", container, str(guest_port))
376419
if rc != 0:

0 commit comments

Comments
 (0)