kaxil commented on code in PR #73990:
URL: https://github.com/apache/airflow/pull/73990#discussion_r4153929727


##########
providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py:
##########
@@ -440,6 +452,125 @@ def list_directory(self, sandbox: str, path: str) -> 
list[tuple[str, bool]]:
             entries.append((name, kind == "d"))
         return entries
 
+    def export_file(self, sandbox: str, path: str, dest: BinaryIO, *, 
max_bytes: int) -> int:
+        """
+        Copy a regular file out of the sandbox into ``dest`` and return the 
bytes written.
+
+        ``dest`` is a writable binary stream, typically an object-storage 
file, and the
+        copy goes through it without the whole file ever being held in worker 
memory,
+        so a file far over :meth:`read_file`'s budget can leave the sandbox. 
Only a
+        regular file is exported: a directory, a device, or a FIFO is refused, 
since
+        none of them has a size to promise a caller. Raise
+        :class:`SandboxFileTooLargeError` instead of copying a file larger than
+        ``max_bytes``, and :class:`SandboxError` when the file changed size 
while it
+        was being copied, which means a process in the sandbox is still 
writing it.
+
+        This default reads the file in slices through :meth:`run_command`, one
+        command per slice, and needs ``stat``, ``tail``, ``head`` and 
``base64`` in the
+        guest. It relies on ``run_command`` returning each slice's output 
intact, or
+        setting ``stdout_truncated`` when it could not, and on nothing but the 
command's
+        own output reaching stdout. Override it when the vendor can stream a 
file out.
+        """
+        quoted = shlex.quote(path)
+        check = self.run_command(
+            sandbox,
+            f"{self._export_checks(quoted, max_bytes)} 
{self._report_export_size()}",
+            timeout=_FILE_OP_TIMEOUT,
+            max_output_bytes=_FILE_OP_OUTPUT_CAP,
+        )
+        self._raise_for_export_status(path, check, max_bytes)
+        size = self._reported_export_size(check.stdout)
+        written = 0
+        while True:
+            # ``tail -c +N`` seeks on a regular file, so each slice costs its 
own
+            # length rather than a read from the start.
+            result = self.run_command(
+                sandbox,
+                f"tail -c +{written + 1} -- {quoted} | head -c 
{_EXPORT_CHUNK_BYTES} | base64",
+                timeout=_FILE_OP_TIMEOUT,
+                max_output_bytes=_EXPORT_CHUNK_BYTES * 2 + 4096,
+            )
+            if result.sandbox_terminated:
+                raise SandboxTerminalError(f"The sandbox ended while {path!r} 
was being exported.")
+            if result.exit_code or result.stdout_truncated:
+                # A truncated slice decodes cleanly into the wrong bytes, so 
it is an
+                # error, not a short read.
+                raise SandboxError(result.stderr.strip() or f"Could not export 
{path!r}.")
+            try:
+                data = base64.b64decode(result.stdout, validate=False)
+            except (binascii.Error, ValueError) as e:
+                raise SandboxError(f"Could not decode {path!r} from the 
sandbox.") from e
+            if written + len(data) > max_bytes:
+                raise SandboxFileTooLargeError(path, written + len(data), 
max_bytes)
+            dest.write(data)
+            written += len(data)
+            if len(data) < _EXPORT_CHUNK_BYTES:
+                break
+        self._check_export_size(path, expected=size, written=written)
+        return written
+
+    @classmethod
+    def _export_checks(cls, quoted: str, max_bytes: int) -> str:
+        """
+        Shell that refuses what :meth:`export_file` will not copy, leaving the 
size in ``$sz``.
+
+        For a backend overriding :meth:`export_file` that still drives the 
guest
+        through a shell, so its refusals mean what the default's do.
+        """
+        return (
+            f"sz=$(stat -Lc %s -- {quoted} 2>/dev/null) || exit 
{cls._MISSING_PATH_STATUS}; "
+            f"[ -d {quoted} ] && exit {cls._IS_DIRECTORY_STATUS}; "
+            f"[ -f {quoted} ] || exit {cls._NOT_REGULAR_FILE_STATUS}; "
+            f'[ "$sz" -gt {max_bytes} ] && {{ 
{cls._report_export_size(to_stderr=True)} exit {cls._TOO_LARGE_STATUS}; }};'
+        )
+
+    @staticmethod
+    def _report_export_size(*, to_stderr: bool = False) -> str:
+        """
+        Shell that prints ``$sz`` on a line of its own, tagged.
+
+        Tagged because the stream it lands on may also carry the vendor CLI's 
own notices.
+        """
+        redirect = " >&2" if to_stderr else ""
+        return f'printf "\\n{_EXPORT_SIZE_TAG}%s\\n" "$sz"{redirect};'
+
+    @staticmethod
+    def _reported_export_size(text: str) -> int:
+        sizes = re.findall(rf"^{_EXPORT_SIZE_TAG}(\d+)$", text, 
flags=re.MULTILINE)
+        if not sizes:
+            raise SandboxError("The sandbox did not report the size of the 
file being exported.")
+        return int(sizes[-1])

Review Comment:
   Renamed to `_print_export_size` (builds the shell that prints it) and 
`_parse_export_size` (reads it back).



##########
providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py:
##########
@@ -469,19 +541,35 @@ def _identity(self) -> _Identity:
         return _Identity(owner=self._owner if self._owner is not None else 
run, holder=holder)
 
     async def __aexit__(self, *args: Any) -> bool | None:
-        await asyncio.to_thread(self._close)
+        # Read here, not in the thread: the exception being handled belongs to 
this frame.
+        run_failed = args[0] is not None or self._run_failed()
+        await asyncio.to_thread(self._close, run_failed=run_failed)
         return None
 
+    def _run_failed(self) -> bool:

Review Comment:
   Done.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to