snowflake-sandbox-python 0.2.2a2__tar.gz → 0.2.2a3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {snowflake_sandbox_python-0.2.2a2/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a3}/PKG-INFO +1 -1
  2. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/pyproject.toml +1 -1
  3. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_version.py +1 -1
  4. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_wire.py +60 -0
  5. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/client.py +43 -3
  6. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/jobs.py +6 -5
  7. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/log_stream.py +8 -0
  8. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_client.py +41 -3
  9. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_log_stream.py +8 -0
  10. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/warm_session.py +10 -7
  11. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3/src/snowflake_sandbox_python.egg-info}/PKG-INFO +1 -1
  12. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/LICENSE +0 -0
  13. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/README.md +0 -0
  14. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/setup.cfg +0 -0
  15. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/setup.py +0 -0
  16. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/__init__.py +0 -0
  17. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_adapter.py +0 -0
  18. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_common.py +0 -0
  19. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_egress_flags.py +0 -0
  20. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_get_command.py +0 -0
  21. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_run_command.py +0 -0
  22. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
  23. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_upload_plan.py +0 -0
  24. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/commands.py +0 -0
  25. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
  26. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/py.typed +0 -0
  27. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/__init__.py +0 -0
  28. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/__main__.py +0 -0
  29. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_ansi.py +0 -0
  30. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_args.py +0 -0
  31. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_assemble.py +0 -0
  32. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_bundle.py +0 -0
  33. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_connection_resolve.py +0 -0
  34. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_deploy_spec.py +0 -0
  35. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_diagnostics.py +0 -0
  36. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_env.py +0 -0
  37. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_files_mixin.py +0 -0
  38. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_fs_ops.py +0 -0
  39. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_globs.py +0 -0
  40. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_hosts.py +0 -0
  41. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
  42. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_mcp_status.py +0 -0
  43. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_retry.py +0 -0
  44. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
  45. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
  46. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
  47. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_protocol.py +0 -0
  48. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_shims.py +0 -0
  49. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sandbox_state.py +0 -0
  50. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_session_registry.py +0 -0
  51. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sse.py +0 -0
  52. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_stage.py +0 -0
  53. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_files_mixin.py +0 -0
  54. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
  55. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_transport.py +0 -0
  56. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_watch.py +0 -0
  57. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_transport.py +0 -0
  58. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_transport_errors.py +0 -0
  59. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_upload_plan.py +0 -0
  60. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_watch.py +0 -0
  61. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/app.py +0 -0
  62. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/config.py +0 -0
  63. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/connect.py +0 -0
  64. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/deploy.py +0 -0
  65. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/egress.py +0 -0
  66. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/exceptions.py +0 -0
  67. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/exec_stream.py +0 -0
  68. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/failure.py +0 -0
  69. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/files.py +0 -0
  70. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/function.py +0 -0
  71. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/image.py +0 -0
  72. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/lifecycle.py +0 -0
  73. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/mcp.py +0 -0
  74. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/mount.py +0 -0
  75. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/py.typed +0 -0
  76. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/secret.py +0 -0
  77. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/session_app.py +0 -0
  78. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/shell.py +0 -0
  79. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
  80. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_files.py +0 -0
  81. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_shell.py +0 -0
  82. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/types.py +0 -0
  83. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +0 -0
  84. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
  85. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
  86. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
  87. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
  88. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/tests/test_docs_references.py +0 -0
  89. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/tests/test_imports.py +0 -0
  90. {snowflake_sandbox_python-0.2.2a2 → snowflake_sandbox_python-0.2.2a3}/tests/test_smoke_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sandbox-python
3
- Version: 0.2.2a2
3
+ Version: 0.2.2a3
4
4
  Summary: Snowflake sandboxes — Python SDK and CLI.
5
5
  Author: Snowflake, Inc.
6
6
  License:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "snowflake-sandbox-python"
7
- version = "0.2.2a2"
7
+ version = "0.2.2a3"
8
8
  description = "Snowflake sandboxes — Python SDK and CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -29,7 +29,7 @@ __all__ = ["__version__"]
29
29
  _DIST_NAME = "snowflake-sandbox-python"
30
30
 
31
31
  # Keep in sync with [project].version in pyproject.toml.
32
- _FALLBACK_VERSION = "0.2.2a2"
32
+ _FALLBACK_VERSION = "0.2.2a3"
33
33
 
34
34
  try:
35
35
  __version__: str = _pkg_version(_DIST_NAME)
@@ -15,6 +15,7 @@ from collections.abc import Mapping, Sequence
15
15
  from datetime import timedelta
16
16
  from typing import TYPE_CHECKING, Any, get_args
17
17
 
18
+ from snowflake.sandbox._runtime._protocol import _REPLY_SENTINEL, _RESULT_SENTINEL
18
19
  from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
19
20
  from snowflake.sandbox.types import MemoryTier, SandboxStatus
20
21
 
@@ -23,6 +24,10 @@ if TYPE_CHECKING:
23
24
 
24
25
  __all__ = [
25
26
  "_parse_status",
27
+ "_is_internal_sentinel_line",
28
+ "_visible_log_lines",
29
+ "_logs_overfetch",
30
+ "_LOGS_OVERFETCH_CAP",
26
31
  "_warn_if_truncated",
27
32
  "_exec_session_id",
28
33
  "_resolve_exec_budget",
@@ -428,3 +433,58 @@ def _exec_reconnect_exhausted(attempt: int, *, delivered: bool) -> bool:
428
433
  never trips this.
429
434
  """
430
435
  return not delivered and attempt >= len(_EXEC_RECONNECT_DELAYS)
436
+
437
+
438
+ # --- internal protocol lines that must never reach a caller's logs() -------------
439
+ #
440
+ # The in-sandbox runner and session daemon report over STDOUT, so their markers land in
441
+ # the log stream alongside the workload's own output: `__SANDBOX_RESULT__<b64>` on
442
+ # managed-process exit and `__SANDBOX_REPLY__<...>` per session reply. They are transport,
443
+ # not output -- `logs()` suppressing them is what LogStream already does for the result
444
+ # marker ("suppress the internal marker from output").
445
+ #
446
+ # They CANNOT be stripped server-side: they are an SDK-level protocol the API knows
447
+ # nothing about, and `_protocol.py` calls their values a compatibility surface (a sandbox
448
+ # launched by an older SDK is still running the older wrapper text), so the wrapper cannot
449
+ # stop printing them either. Internal consumers therefore read the UNFILTERED text via
450
+ # `_logs_raw()`; only the public `logs()` filters.
451
+ _INTERNAL_SENTINELS = (_RESULT_SENTINEL, _REPLY_SENTINEL)
452
+
453
+ # How many extra lines to ask the server for when a caller wants the last `tail` VISIBLE
454
+ # lines. `tail` is applied SERVER-side, before filtering, so asking for exactly `tail`
455
+ # and then dropping a sentinel returns fewer lines than requested -- the bug that made
456
+ # logs(tail=2) answer ['line5', '__SANDBOX_RESULT__...'] and then, once filtered,
457
+ # ['line5'] alone. The fetch grows until enough visible lines come back, the server runs
458
+ # out of log, or the cap is hit; the cap keeps a session whose recent history is mostly
459
+ # reply markers from turning one logs() call into an unbounded fetch.
460
+ _LOGS_OVERFETCH_MARGIN = 8
461
+ _LOGS_OVERFETCH_CAP = 4096
462
+
463
+
464
+ def _is_internal_sentinel_line(text: str) -> bool:
465
+ """True when a log line carries an SDK-internal protocol marker."""
466
+ return any(sentinel in text for sentinel in _INTERNAL_SENTINELS)
467
+
468
+
469
+ def _visible_log_lines(raw_text: str) -> list[str]:
470
+ r"""The caller-visible lines of a raw log snapshot, internal markers removed.
471
+
472
+ An empty snapshot yields no lines rather than one empty line, so
473
+ ``"\n".join(...)`` round-trips "" instead of inventing a blank line.
474
+ """
475
+ if not raw_text:
476
+ return []
477
+ return [ln for ln in raw_text.split("\n") if not _is_internal_sentinel_line(ln)]
478
+
479
+
480
+ def _logs_overfetch(tail: int, previous: int = 0) -> int:
481
+ """Server-side `tail` to request in order to end up with `tail` VISIBLE lines.
482
+
483
+ `tail=0` means "everything", which needs no widening. Otherwise start a margin above
484
+ the request and double on each retry, capped -- see `_LOGS_OVERFETCH_CAP`.
485
+ """
486
+ if tail <= 0:
487
+ return 0
488
+ if previous <= 0:
489
+ return tail + _LOGS_OVERFETCH_MARGIN
490
+ return min(previous * 2, _LOGS_OVERFETCH_CAP)
@@ -57,6 +57,7 @@ from snowflake.sandbox._transport import (
57
57
  from snowflake.sandbox._wire import (
58
58
  _EXEC_RECONNECT_DELAYS,
59
59
  _EXEC_TIMEOUT_GRACE_S,
60
+ _LOGS_OVERFETCH_CAP,
60
61
  _POLL_UNKNOWN_EXIT_CODE,
61
62
  _PROBE_BUDGET_S,
62
63
  _PROBE_NOT_FOUND_RETRY_S,
@@ -66,8 +67,10 @@ from snowflake.sandbox._wire import (
66
67
  _exec_session_id,
67
68
  _fresh_exec_body,
68
69
  _idle_suspend_minutes,
70
+ _logs_overfetch,
69
71
  _parse_status,
70
72
  _resolve_exec_budget,
73
+ _visible_log_lines,
71
74
  _warn_if_truncated,
72
75
  )
73
76
  from snowflake.sandbox.egress import Egress, compile_egress
@@ -772,10 +775,45 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
772
775
  `tail` limits to the last N lines. Only meaningful for command containers
773
776
  (created with `command=`).
774
777
 
778
+ SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
779
+ run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
780
+ reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
781
+ beside the workload's own output. They are transport, not output. `tail` counts
782
+ VISIBLE lines, so `logs(tail=2)` on a command printing `line1..line5` answers the two
783
+ lines `line4` and `line5`, never spending one of the two on a marker.
784
+
775
785
  Example:
776
786
  output = await sb.logs(tail=100)
777
787
  print(output)
778
788
  """
789
+ fetch = _logs_overfetch(tail)
790
+ while True:
791
+ raw = await self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms)
792
+ raw_count = len(raw.split("\n")) if raw else 0
793
+ visible = _visible_log_lines(raw)
794
+ if tail <= 0 or len(visible) >= tail:
795
+ break
796
+ # Short of `tail` visible lines. Only a WIDER fetch can help, and only while
797
+ # the server still has more to give: `raw_count < fetch` means we already hold
798
+ # the whole log, so filtering -- not the window -- is why there are fewer.
799
+ if raw_count < fetch or fetch >= _LOGS_OVERFETCH_CAP:
800
+ break
801
+ fetch = _logs_overfetch(tail, fetch)
802
+ # `tail > 0`, not `if tail`: a NEGATIVE tail is truthy and visible[-(-1):] is
803
+ # visible[1:], which would silently drop the FIRST lines instead of returning
804
+ # everything. `_logs_overfetch` already treats tail <= 0 as "no window".
805
+ if tail > 0:
806
+ visible = visible[-tail:]
807
+ return "\n".join(visible)
808
+
809
+ async def _logs_raw(self, *, tail: int = 0, since_ts_ms: int = 0) -> str:
810
+ """The log snapshot EXACTLY as the server returns it, markers included.
811
+
812
+ Internal consumers that read the runner's or daemon's stdout protocol
813
+ (`wait()`, `Job.get`/`Job.status`, `send()`) must use this, not `logs()` --
814
+ `logs()` strips the very lines they parse. Not public: a caller wanting the
815
+ sandbox's output wants `logs()`.
816
+ """
779
817
  if not self._created:
780
818
  await self._ensure_created()
781
819
  if self._id is None: # pragma: no cover - defensive
@@ -1852,7 +1890,7 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1852
1890
  # it (the connect() reconnect path) fall through to the server-reported
1853
1891
  # exit code rather than accepting a forgeable, unauthenticated line.
1854
1892
  if self._result_nonce is not None:
1855
- parsed = _parse_result_sentinel(await self.logs(), nonce=self._result_nonce)
1893
+ parsed = _parse_result_sentinel(await self._logs_raw(), nonce=self._result_nonce)
1856
1894
  if parsed is not None:
1857
1895
  parsed.logs_ref = self._id
1858
1896
  return parsed
@@ -1910,7 +1948,7 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1910
1948
  await self.exec(_mailbox_append_cmd(msg_id, message))
1911
1949
  deadline = _time.monotonic() + timeout
1912
1950
  while True:
1913
- reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1951
+ reply = _parse_reply_sentinel(await self._logs_raw(), msg_id, nonce=self._reply_nonce)
1914
1952
  if reply is not None:
1915
1953
  return reply
1916
1954
  # A terminal container will never reply; surface that rather than spending
@@ -1918,7 +1956,9 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1918
1956
  # the cached status) is the only live read of the container's lifecycle.
1919
1957
  if await self.refresh() in TERMINAL_STATUSES:
1920
1958
  # One last read: the daemon may have replied just before exiting.
1921
- reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1959
+ reply = _parse_reply_sentinel(
1960
+ await self._logs_raw(), msg_id, nonce=self._reply_nonce
1961
+ )
1922
1962
  if reply is not None:
1923
1963
  return reply
1924
1964
  raise SandboxError(_ended_before_reply(self.status, msg_id))
@@ -211,7 +211,7 @@ class Job(_JobHandle):
211
211
  terminal state and server-reported exit code, never a forgeable line."""
212
212
  sb = await get_sandbox(self.id, connection=self._connection)
213
213
  if self.nonce is not None:
214
- parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
214
+ parsed = _parse_result_sentinel(await sb._logs_raw(), nonce=self.nonce)
215
215
  if parsed is not None:
216
216
  # A trusted sentinel reports succeeded/failed; status() is a live
217
217
  # lifecycle verdict where timed_out is not a member, so fold it in.
@@ -226,7 +226,8 @@ class Job(_JobHandle):
226
226
  async def get(self, timeout: float | None = None, poll_s: float = 3.0) -> RunResult:
227
227
  """Poll for the Job's result.
228
228
 
229
- Fetches ``get_sandbox(id).logs()`` and scans for the LAST sentinel;
229
+ Fetches ``get_sandbox(id)._logs_raw()`` -- the UNFILTERED channel, because the
230
+ public ``logs()`` strips the very sentinel this scans for -- and takes the LAST one;
230
231
  returns the decoded `RunResult` as soon as one appears. If the
231
232
  container reaches a terminal state (``dead`` or ``failed``) with no sentinel, returns
232
233
  ``RunResult(status="failed", ...)``. On *timeout* (when set) before
@@ -239,7 +240,7 @@ class Job(_JobHandle):
239
240
  # Only trust the stdout sentinel when we hold the run's nonce;
240
241
  # a from_id() handle has none, so fall through to the server state.
241
242
  if self.nonce is not None:
242
- parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
243
+ parsed = _parse_result_sentinel(await sb._logs_raw(), nonce=self.nonce)
243
244
  if parsed is not None:
244
245
  parsed.logs_ref = self.id
245
246
  return parsed
@@ -288,7 +289,7 @@ class SyncJob(_JobHandle):
288
289
  container's own terminal state."""
289
290
  sb = get_sandbox_sync(self.id, connection=self._connection)
290
291
  if self.nonce is not None:
291
- parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
292
+ parsed = _parse_result_sentinel(sb._logs_raw(), nonce=self.nonce)
292
293
  if parsed is not None:
293
294
  # See Job.status: fold the non-live timed_out into failed.
294
295
  return "failed" if parsed.status == "timed_out" else parsed.status
@@ -308,7 +309,7 @@ class SyncJob(_JobHandle):
308
309
  while True:
309
310
  sb = get_sandbox_sync(self.id, connection=self._connection)
310
311
  if self.nonce is not None:
311
- parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
312
+ parsed = _parse_result_sentinel(sb._logs_raw(), nonce=self.nonce)
312
313
  if parsed is not None:
313
314
  parsed.logs_ref = self.id
314
315
  return parsed
@@ -24,6 +24,7 @@ from collections.abc import AsyncIterator
24
24
  from typing import TYPE_CHECKING, Any, Self
25
25
 
26
26
  from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
27
+ from snowflake.sandbox._wire import _is_internal_sentinel_line
27
28
  from snowflake.sandbox.exceptions import SandboxConflictError, SandboxError
28
29
  from snowflake.sandbox.types import TERMINAL_STATUSES, StreamName
29
30
 
@@ -105,6 +106,13 @@ class LogStream(AsyncIterator[str]):
105
106
  if _RESULT_SENTINEL in text:
106
107
  self._process_done = True
107
108
  continue
109
+ # Any OTHER internal marker (the session daemon's __SANDBOX_REPLY__) is
110
+ # suppressed too, but is NOT end-of-stream -- a session keeps running after a
111
+ # reply. Checked after the result marker so that one still sets the flag.
112
+ # Without this the streamed view leaked a marker the snapshot logs() hides:
113
+ # same sandbox, two different answers about what its output was.
114
+ if _is_internal_sentinel_line(text):
115
+ continue
108
116
  ts = int(ln.get("ts") or 0)
109
117
  if ts:
110
118
  # Compare against the batch-start watermark, not one mutated
@@ -55,6 +55,7 @@ from snowflake.sandbox._sync_transport import (
55
55
  from snowflake.sandbox._wire import (
56
56
  _EXEC_RECONNECT_DELAYS,
57
57
  _EXEC_TIMEOUT_GRACE_S,
58
+ _LOGS_OVERFETCH_CAP,
58
59
  _POLL_UNKNOWN_EXIT_CODE,
59
60
  _PROBE_BUDGET_S,
60
61
  _PROBE_NOT_FOUND_RETRY_S,
@@ -64,8 +65,10 @@ from snowflake.sandbox._wire import (
64
65
  _exec_session_id,
65
66
  _fresh_exec_body,
66
67
  _idle_suspend_minutes,
68
+ _logs_overfetch,
67
69
  _parse_status,
68
70
  _resolve_exec_budget,
71
+ _visible_log_lines,
69
72
  _warn_if_truncated,
70
73
  )
71
74
  from snowflake.sandbox.egress import Egress, compile_egress
@@ -771,10 +774,45 @@ class Sandbox(_FilesMixin, _SandboxState):
771
774
  `tail` limits to the last N lines. Only meaningful for command containers
772
775
  (created with `command=`).
773
776
 
777
+ SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
778
+ run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
779
+ reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
780
+ beside the workload's own output. They are transport, not output. `tail` counts
781
+ VISIBLE lines, so `logs(tail=2)` on a command printing `line1..line5` answers the two
782
+ lines `line4` and `line5`, never spending one of the two on a marker.
783
+
774
784
  Example:
775
785
  output = sb.logs(tail=100)
776
786
  print(output)
777
787
  """
788
+ fetch = _logs_overfetch(tail)
789
+ while True:
790
+ raw = self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms)
791
+ raw_count = len(raw.split("\n")) if raw else 0
792
+ visible = _visible_log_lines(raw)
793
+ if tail <= 0 or len(visible) >= tail:
794
+ break
795
+ # Short of `tail` visible lines. Only a WIDER fetch can help, and only while
796
+ # the server still has more to give: `raw_count < fetch` means we already hold
797
+ # the whole log, so filtering -- not the window -- is why there are fewer.
798
+ if raw_count < fetch or fetch >= _LOGS_OVERFETCH_CAP:
799
+ break
800
+ fetch = _logs_overfetch(tail, fetch)
801
+ # `tail > 0`, not `if tail`: a NEGATIVE tail is truthy and visible[-(-1):] is
802
+ # visible[1:], which would silently drop the FIRST lines instead of returning
803
+ # everything. `_logs_overfetch` already treats tail <= 0 as "no window".
804
+ if tail > 0:
805
+ visible = visible[-tail:]
806
+ return "\n".join(visible)
807
+
808
+ def _logs_raw(self, *, tail: int = 0, since_ts_ms: int = 0) -> str:
809
+ """The log snapshot EXACTLY as the server returns it, markers included.
810
+
811
+ Internal consumers that read the runner's or daemon's stdout protocol
812
+ (`wait()`, `Job.get`/`Job.status`, `send()`) must use this, not `logs()` --
813
+ `logs()` strips the very lines they parse. Not public: a caller wanting the
814
+ sandbox's output wants `logs()`.
815
+ """
778
816
  if not self._created:
779
817
  self._ensure_created()
780
818
  if self._id is None: # pragma: no cover - defensive
@@ -1801,7 +1839,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1801
1839
  # it (the connect() reconnect path) fall through to the server-reported
1802
1840
  # exit code rather than accepting a forgeable, unauthenticated line.
1803
1841
  if self._result_nonce is not None:
1804
- parsed = _parse_result_sentinel(self.logs(), nonce=self._result_nonce)
1842
+ parsed = _parse_result_sentinel(self._logs_raw(), nonce=self._result_nonce)
1805
1843
  if parsed is not None:
1806
1844
  parsed.logs_ref = self._id
1807
1845
  return parsed
@@ -1858,7 +1896,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1858
1896
  self.exec(_mailbox_append_cmd(msg_id, message))
1859
1897
  deadline = _time.monotonic() + timeout
1860
1898
  while True:
1861
- reply = _parse_reply_sentinel(self.logs(), msg_id, nonce=self._reply_nonce)
1899
+ reply = _parse_reply_sentinel(self._logs_raw(), msg_id, nonce=self._reply_nonce)
1862
1900
  if reply is not None:
1863
1901
  return reply
1864
1902
  # A terminal container will never reply; surface that rather than spending
@@ -1866,7 +1904,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1866
1904
  # the cached status) is the only live read of the container's lifecycle.
1867
1905
  if self.refresh() in TERMINAL_STATUSES:
1868
1906
  # One last read: the daemon may have replied just before exiting.
1869
- reply = _parse_reply_sentinel(self.logs(), msg_id, nonce=self._reply_nonce)
1907
+ reply = _parse_reply_sentinel(self._logs_raw(), msg_id, nonce=self._reply_nonce)
1870
1908
  if reply is not None:
1871
1909
  return reply
1872
1910
  raise SandboxError(_ended_before_reply(self.status, msg_id))
@@ -23,6 +23,7 @@ from collections.abc import Iterator
23
23
  from typing import TYPE_CHECKING, Self
24
24
 
25
25
  from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
26
+ from snowflake.sandbox._wire import _is_internal_sentinel_line
26
27
  from snowflake.sandbox.log_stream import (
27
28
  _DRAIN_AFTER_TERMINAL_S,
28
29
  _POLL_INTERVAL_S,
@@ -90,6 +91,13 @@ class SyncLogStream(Iterator[str]):
90
91
  if _RESULT_SENTINEL in text:
91
92
  self._process_done = True
92
93
  continue
94
+ # Any OTHER internal marker (the session daemon's __SANDBOX_REPLY__) is
95
+ # suppressed too, but is NOT end-of-stream -- a session keeps running after a
96
+ # reply. Checked after the result marker so that one still sets the flag.
97
+ # Without this the streamed view leaked a marker the snapshot logs() hides:
98
+ # same sandbox, two different answers about what its output was.
99
+ if _is_internal_sentinel_line(text):
100
+ continue
93
101
  ts = int(ln.get("ts") or 0)
94
102
  if ts:
95
103
  if ts < start_ts:
@@ -28,7 +28,8 @@ redeployed, never adopted — see `agent_session`.
28
28
 
29
29
  * `Session.send()` mints a ``msg_id`` (uuid hex), execs a shell-quoted
30
30
  ``printf … >> /var/tmp/sandbox_mailbox.jsonl`` to append the message, then polls
31
- ``logs()`` until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
31
+ ``_logs_raw()`` -- the UNFILTERED channel, since the public ``logs()`` suppresses
32
+ internal markers -- until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
32
33
  appears, base64-decodes it, and returns the reply string. A timeout raises
33
34
  `SandboxExecTimeoutError`.
34
35
 
@@ -304,7 +305,8 @@ class Session:
304
305
 
305
306
  Mints a ``msg_id``, execs a shell-quoted append into
306
307
  ``/var/tmp/sandbox_mailbox.jsonl`` inside the live container, then polls
307
- ``logs()`` until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
308
+ ``_logs_raw()`` (the unfiltered channel; public ``logs()`` hides the marker)
309
+ until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
308
310
  Base64-decodes the payload and returns the reply string.
309
311
 
310
312
  Raises `SandboxError` if the container reaches a terminal state without
@@ -316,7 +318,7 @@ class Session:
316
318
 
317
319
  deadline = time.monotonic() + timeout
318
320
  while True:
319
- log_text = await sb.logs()
321
+ log_text = await sb._logs_raw()
320
322
  reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
321
323
  if reply is not None:
322
324
  return reply
@@ -327,7 +329,7 @@ class Session:
327
329
  if await sb.refresh() in TERMINAL_STATUSES:
328
330
  # One last read first -- the daemon may have replied just before
329
331
  # exiting, and that reply is the answer, not an error.
330
- reply = _parse_reply_sentinel(await sb.logs(), msg_id, nonce=self.nonce)
332
+ reply = _parse_reply_sentinel(await sb._logs_raw(), msg_id, nonce=self.nonce)
331
333
  if reply is not None:
332
334
  return reply
333
335
  raise SandboxError(_ended_before_reply(sb.status, msg_id))
@@ -342,7 +344,8 @@ class Session:
342
344
  Same mailbox round-trip as `send`, blocking instead of awaiting: mints a
343
345
  ``msg_id``, execs the shell-quoted append into
344
346
  ``/var/tmp/sandbox_mailbox.jsonl`` inside the live container (via the
345
- synchronous `Sandbox`), then polls ``logs()`` until the daemon prints
347
+ synchronous `Sandbox`), then polls ``_logs_raw()`` -- the unfiltered channel,
348
+ since public ``logs()`` hides the marker -- until the daemon prints
346
349
  ``__SANDBOX_REPLY__<msg_id>__<b64>``, base64-decodes it, and returns the
347
350
  reply string. The request built and the reply parsed are byte-identical
348
351
  to `send`'s — both go through the shared ``_mailbox_append_cmd`` /
@@ -365,14 +368,14 @@ class Session:
365
368
 
366
369
  deadline = time.monotonic() + timeout
367
370
  while True:
368
- log_text = sb.logs()
371
+ log_text = sb._logs_raw()
369
372
  reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
370
373
  if reply is not None:
371
374
  return reply
372
375
  # As in `send`: a terminal container is not a timeout, and refresh() is
373
376
  # the only live read of its status.
374
377
  if sb.refresh() in TERMINAL_STATUSES:
375
- reply = _parse_reply_sentinel(sb.logs(), msg_id, nonce=self.nonce)
378
+ reply = _parse_reply_sentinel(sb._logs_raw(), msg_id, nonce=self.nonce)
376
379
  if reply is not None:
377
380
  return reply
378
381
  raise SandboxError(_ended_before_reply(sb.status, msg_id))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sandbox-python
3
- Version: 0.2.2a2
3
+ Version: 0.2.2a3
4
4
  Summary: Snowflake sandboxes — Python SDK and CLI.
5
5
  Author: Snowflake, Inc.
6
6
  License: