snowflake-sandbox-python 0.2.2a3__tar.gz → 0.2.2a4__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.
- {snowflake_sandbox_python-0.2.2a3/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a4}/PKG-INFO +1 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/pyproject.toml +23 -6
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_egress_flags.py +6 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_run_command.py +7 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_diagnostics.py +8 -6
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_version.py +1 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/client.py +31 -4
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/connect.py +1 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/egress.py +114 -40
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/sync_client.py +31 -4
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4/src/snowflake_sandbox_python.egg-info}/PKG-INFO +1 -1
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/tests/test_docs_references.py +286 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/LICENSE +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/README.md +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/setup.cfg +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/setup.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_adapter.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_common.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_get_command.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/_upload_plan.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/commands.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/cli_sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/__main__.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_ansi.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_args.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_assemble.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_bundle.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_connection_resolve.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_deploy_spec.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_env.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_files_mixin.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_globs.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_hosts.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_mcp_status.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_retry.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_runtime/_protocol.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_runtime/_shims.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sandbox_state.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_session_registry.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sse.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_stage.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sync_files_mixin.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sync_transport.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sync_watch.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_transport.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_transport_errors.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_upload_plan.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_watch.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_wire.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/app.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/config.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/deploy.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/exceptions.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/failure.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/files.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/function.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/image.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/jobs.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/lifecycle.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/mcp.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/mount.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/secret.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/session_app.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/shell.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/sync_files.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/sync_log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/sync_shell.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/types.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/warm_session.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/tests/test_imports.py +0 -0
- {snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/tests/test_smoke_offline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "snowflake-sandbox-python"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2a4"
|
|
8
8
|
description = "Snowflake sandboxes — Python SDK and CLI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -146,11 +146,28 @@ testpaths = ["tests"]
|
|
|
146
146
|
addopts = "-m 'not live'"
|
|
147
147
|
markers = [
|
|
148
148
|
"live: tests that run against a real sandbox-api deployment",
|
|
149
|
-
# The promotion gate. A fast
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
149
|
+
# The promotion gate. A fast subset of `live` that a deployment must pass before it
|
|
150
|
+
# is promoted to the next tier. Chosen so every member fails LOUDLY on an unhealthy
|
|
151
|
+
# deployment: nothing that skips on error, and nothing unbounded. Run with `-m smoke`.
|
|
152
|
+
#
|
|
153
|
+
# Measured on qa6, 2026-09-24: **15 tests, ~47s**. (The previous note here said
|
|
154
|
+
# "~10 creates, 3-5 min" — the count was right for its time but the duration was never
|
|
155
|
+
# close; don't budget against it.) Adding the two egress rows cost +16.6s over the
|
|
156
|
+
# 13-test predecessor's 29.9s. Worth re-measuring rather than trusting this line if the
|
|
157
|
+
# gate's cost ever becomes the question.
|
|
158
|
+
#
|
|
159
|
+
# This used to say "no egress tests (they assert 'BLOCKED' and so pass for the wrong
|
|
160
|
+
# reason when networking is dead)". That reasoning was correct and is why egress was
|
|
161
|
+
# excluded — but it no longer describes the gate. The egress level's EXPLICIT rows are
|
|
162
|
+
# now gated, because the test that carries them defeats that specific objection: its
|
|
163
|
+
# restrictive leg asserts a liveness CONTROL (the sandbox can still reach its Snowflake
|
|
164
|
+
# account) so "refused" cannot be confused with "the proxy is refusing everything", and
|
|
165
|
+
# its permissive leg asserts a 200, which a dead network cannot produce.
|
|
166
|
+
#
|
|
167
|
+
# Still excluded, deliberately: the tier's DEFAULT egress level. Its correct value is
|
|
168
|
+
# tier-dependent while the opt-in flip rolls (permissive before, closed after), so
|
|
169
|
+
# gating it would freeze the train on precisely the change being rolled out. It stays in
|
|
170
|
+
# the broader `-m live` suite, where a human reads it against a known switch state.
|
|
154
171
|
"smoke: promotion-gate subset of the live suite",
|
|
155
172
|
]
|
|
156
173
|
filterwarnings = [
|
|
@@ -106,7 +106,12 @@ def build_egress(
|
|
|
106
106
|
|
|
107
107
|
egress = Egress(
|
|
108
108
|
external_access_integrations=integrations,
|
|
109
|
-
|
|
109
|
+
# Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it is
|
|
110
|
+
# released, renaming it would break users' scripts, and that is a separate decision
|
|
111
|
+
# from the library parameter's name. So the flag spelling and the parameter spelling
|
|
112
|
+
# differ here deliberately — the alternative is the CLI depending on a deprecated
|
|
113
|
+
# parameter, which is worse.
|
|
114
|
+
**({"allow_public_package_registries": False} if no_default_egress else {}),
|
|
110
115
|
)
|
|
111
116
|
# Validate now, not at create time. `compile_egress` is pure and is what the client
|
|
112
117
|
# runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
|
|
@@ -312,7 +312,13 @@ def _print_inferred_plan(plan: _RunPlan) -> None:
|
|
|
312
312
|
typer.echo(f"command: {plan.command or '(image default)'}")
|
|
313
313
|
if plan.egress is not None:
|
|
314
314
|
eais = getattr(plan.egress, "external_access_integrations", ())
|
|
315
|
-
|
|
315
|
+
# Read the RESOLVER, not any one spelling. There are three accepted names for this
|
|
316
|
+
# control and reading a specific field goes silently blind when a caller (or this
|
|
317
|
+
# CLI) uses a different one — which is exactly what happened: switching the flag's
|
|
318
|
+
# construction to allow_public_package_registries made this summary stop reporting
|
|
319
|
+
# "default-egress=closed" while the sandbox was still correctly closed. A dry run
|
|
320
|
+
# that under-reports the egress posture is worse than no dry run.
|
|
321
|
+
closed = getattr(plan.egress, "default_egress_allowed", None) is False
|
|
316
322
|
bits = [f"eai={','.join(eais)}" if eais else "", "default-egress=closed" if closed else ""]
|
|
317
323
|
if plan.secrets:
|
|
318
324
|
bits.append(f"secrets={len(plan.secrets)}")
|
|
@@ -127,19 +127,19 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
|
|
|
127
127
|
problems.append(Problem("error", str(exc)))
|
|
128
128
|
|
|
129
129
|
# A secret's `host` and Snowflake reachability are different controls: the first is
|
|
130
|
-
# enforced at the credential swap, the second by
|
|
131
|
-
# booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
|
|
130
|
+
# enforced at the credential swap, the second by the egress level
|
|
131
|
+
# (allow_public_package_registries) / the group booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
|
|
132
132
|
# silently useless, so say so. (eai_hosts is the SPCS perimeter and does not
|
|
133
133
|
# affect Snowflake reachability, so it is deliberately not consulted here.)
|
|
134
134
|
# Only warn when egress was *explicitly* closed AND no EAI was granted:
|
|
135
|
-
# -
|
|
135
|
+
# - the egress level is tri-state: left unset the platform applies its permissive
|
|
136
136
|
# default, and actual reachability is then an Istio question we cannot answer
|
|
137
137
|
# from here — warning on it would be noise on the common path.
|
|
138
138
|
# - allow_github / allow_pypi are NOT credited here, and now never could be: both
|
|
139
139
|
# are inert, so neither makes a host reachable in any mode. The retired
|
|
140
140
|
# allowed_egress_hosts is likewise not consulted — hosts named there are ignored
|
|
141
141
|
# platform-side.
|
|
142
|
-
# - Under
|
|
142
|
+
# - Under allow_public_package_registries=False the only grant left is an EAI, whose rules
|
|
143
143
|
# live in Snowflake and cannot be resolved from here — so an EAI being present is
|
|
144
144
|
# taken as "the caller has said how" (loop-invariant, hence hoisted out below).
|
|
145
145
|
eg = spec.egress
|
|
@@ -153,13 +153,15 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
|
|
|
153
153
|
hint = (
|
|
154
154
|
f"grant {h!r} with an External Access Integration and pass "
|
|
155
155
|
f'external_access_integrations=("MY_EAI",) — under '
|
|
156
|
-
f"
|
|
156
|
+
f"allow_public_package_registries=False that is the only grant the "
|
|
157
|
+
f"platform applies"
|
|
157
158
|
)
|
|
158
159
|
problems.append(
|
|
159
160
|
Problem(
|
|
160
161
|
"warn",
|
|
161
162
|
f"secret {s.fqn!r} is scoped to host {h!r}, which the sandbox cannot "
|
|
162
|
-
f"reach with
|
|
163
|
+
f"reach with allow_public_package_registries=False — the credential "
|
|
164
|
+
f"could never be used",
|
|
163
165
|
hint,
|
|
164
166
|
)
|
|
165
167
|
)
|
|
@@ -132,7 +132,7 @@ async def _coerce_image(image: str | Image, transport: Transport | None) -> str:
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
class AsyncSandbox(_FilesMixin, _SandboxState):
|
|
135
|
-
"""A
|
|
135
|
+
"""A Cortex Sandbox (async API).
|
|
136
136
|
|
|
137
137
|
``await create()`` is the factory, and the only creation path -- ``AsyncSandbox(...)``
|
|
138
138
|
raises. A constructor cannot be awaited, which is the deeper reason it was never the
|
|
@@ -769,12 +769,26 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
|
|
|
769
769
|
return
|
|
770
770
|
await self._wait_until_ready(timeout=timeout)
|
|
771
771
|
|
|
772
|
-
async def logs(
|
|
772
|
+
async def logs(
|
|
773
|
+
self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
|
|
774
|
+
) -> str:
|
|
773
775
|
"""Return the container's captured stdout and stderr as a string.
|
|
774
776
|
|
|
775
777
|
`tail` limits to the last N lines. Only meaningful for command containers
|
|
776
778
|
(created with `command=`).
|
|
777
779
|
|
|
780
|
+
`source` selects which stream to read, defaulting to the server's `all` -- the
|
|
781
|
+
entrypoint plus the `command=` process:
|
|
782
|
+
|
|
783
|
+
- ``"exec"`` -- what the commands you ran with `exec()` printed, which no other
|
|
784
|
+
source includes. `exec()` also returns that output directly; this is how you
|
|
785
|
+
read it again afterwards.
|
|
786
|
+
- ``"base"`` / ``"managed"`` -- one of the two long-lived streams on its own.
|
|
787
|
+
|
|
788
|
+
`pid` narrows an exec read to one producer. Without it every exec is merged and
|
|
789
|
+
the returned text carries no per-line attribution; `pid=` is how you read one
|
|
790
|
+
command's output on its own.
|
|
791
|
+
|
|
778
792
|
SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
|
|
779
793
|
run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
|
|
780
794
|
reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
|
|
@@ -785,10 +799,13 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
|
|
|
785
799
|
Example:
|
|
786
800
|
output = await sb.logs(tail=100)
|
|
787
801
|
print(output)
|
|
802
|
+
|
|
803
|
+
# What the commands you ran with `exec()` printed.
|
|
804
|
+
print(await sb.logs(source="exec"))
|
|
788
805
|
"""
|
|
789
806
|
fetch = _logs_overfetch(tail)
|
|
790
807
|
while True:
|
|
791
|
-
raw = await self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms)
|
|
808
|
+
raw = await self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms, source=source, pid=pid)
|
|
792
809
|
raw_count = len(raw.split("\n")) if raw else 0
|
|
793
810
|
visible = _visible_log_lines(raw)
|
|
794
811
|
if tail <= 0 or len(visible) >= tail:
|
|
@@ -806,7 +823,9 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
|
|
|
806
823
|
visible = visible[-tail:]
|
|
807
824
|
return "\n".join(visible)
|
|
808
825
|
|
|
809
|
-
async def _logs_raw(
|
|
826
|
+
async def _logs_raw(
|
|
827
|
+
self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
|
|
828
|
+
) -> str:
|
|
810
829
|
"""The log snapshot EXACTLY as the server returns it, markers included.
|
|
811
830
|
|
|
812
831
|
Internal consumers that read the runner's or daemon's stdout protocol
|
|
@@ -825,6 +844,14 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
|
|
|
825
844
|
q.append(f"tail={tail}")
|
|
826
845
|
if since_ts_ms:
|
|
827
846
|
q.append(f"since={since_ts_ms}")
|
|
847
|
+
# Forwarded verbatim rather than validated here: the server owns the vocabulary
|
|
848
|
+
# and names the valid values in its rejection, so a client-side allowlist would go
|
|
849
|
+
# stale as soon as a source is added and would turn a 400 that explains itself
|
|
850
|
+
# into one that does not.
|
|
851
|
+
if source:
|
|
852
|
+
q.append(f"source={source}")
|
|
853
|
+
if pid:
|
|
854
|
+
q.append(f"pid={pid}")
|
|
828
855
|
path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
|
|
829
856
|
try:
|
|
830
857
|
resp = await self._transport.request(
|
|
@@ -251,7 +251,7 @@ def _make_connection(**kwargs: Any) -> Any:
|
|
|
251
251
|
|
|
252
252
|
|
|
253
253
|
def get_snowflake_connection(**kwargs: Any) -> Any:
|
|
254
|
-
"""Return a Snowflake connection for code running inside a
|
|
254
|
+
"""Return a Snowflake connection for code running inside a Cortex Sandbox.
|
|
255
255
|
|
|
256
256
|
Resolves the credential the way the platform provides it, in order: the
|
|
257
257
|
``SNOWFLAKE_PAT``/``SNOWFLAKE_TOKEN`` env value; a token file named by
|
|
@@ -48,7 +48,7 @@ def _retired_host_list_message(spelling: str) -> str:
|
|
|
48
48
|
f"locally instead: the sandbox would start healthy and silently fail to reach "
|
|
49
49
|
f"them.\n\n"
|
|
50
50
|
f"Grant the hosts with an External Access Integration and name it instead:\n"
|
|
51
|
-
f' Egress(
|
|
51
|
+
f' Egress(allow_public_package_registries=False, external_access_integrations=("MY_EAI",))\n\n'
|
|
52
52
|
f"Creating one (the network rule cannot live in a personal USER$ database):\n"
|
|
53
53
|
f" CREATE NETWORK RULE db.schema.my_rule MODE = EGRESS TYPE = HOST_PORT\n"
|
|
54
54
|
f" VALUE_LIST = ('example.com:443');\n"
|
|
@@ -71,11 +71,30 @@ def _nonempty_host_value(raw: object) -> bool:
|
|
|
71
71
|
return len(raw) > 0
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
# The REST spelling
|
|
75
|
-
#
|
|
76
|
-
#
|
|
74
|
+
# The REST spelling this client SENDS for the egress tri-state. Named once because the
|
|
75
|
+
# client parameter and the wire key have diverged and two places translate between them
|
|
76
|
+
# (Egress.to_wire and compile_egress's raw-dict path).
|
|
77
|
+
#
|
|
78
|
+
# DELIBERATELY still "allow_internet", even though sandbox-api now has
|
|
79
|
+
# allow_public_package_registries (sandbox-api#349) and every tier accepts it (measured
|
|
80
|
+
# 2026-09-24). Do NOT "fix" this to the new key as a tidy-up — that couples a client-side
|
|
81
|
+
# naming improvement to a deployment risk:
|
|
82
|
+
#
|
|
83
|
+
# - sandbox-api keeps honouring allow_internet deliberately, for pinned SDKs exactly like
|
|
84
|
+
# this one (containers/egress.go: "the superseded one and still means exactly the same
|
|
85
|
+
# thing"; containers/models.go names "every pinned sandbox-sdk" as the reason).
|
|
86
|
+
# - A prod rollback to a pre-#349 build would 400 every create carrying the NEW key, while
|
|
87
|
+
# the old key keeps working against every build, reverted or not.
|
|
88
|
+
#
|
|
89
|
+
# Switching it is its own decision, gated on the prod rollback window closing — not on the
|
|
90
|
+
# field being accepted. Until then this is the one spelling that is safe everywhere.
|
|
77
91
|
_DEFAULT_EGRESS_WIRE_KEY = "allow_internet"
|
|
78
92
|
|
|
93
|
+
# The current, honest client parameter name; it matches sandbox-api's field and the Snowsight
|
|
94
|
+
# dialog copy ("Snowflake and public package registries"). The two older spellings below stay
|
|
95
|
+
# accepted because they shipped.
|
|
96
|
+
_EGRESS_LEVEL_PARAM = "allow_public_package_registries"
|
|
97
|
+
|
|
79
98
|
|
|
80
99
|
@dataclass(frozen=True)
|
|
81
100
|
class Egress:
|
|
@@ -87,8 +106,8 @@ class Egress:
|
|
|
87
106
|
|
|
88
107
|
| level | how | reaches |
|
|
89
108
|
|---|---|---|
|
|
90
|
-
| baseline | the default, or `
|
|
91
|
-
| closed | `
|
|
109
|
+
| baseline | the default, or `allow_public_package_registries=True` | Snowflake, the cloud-storage stages, and ~43 package-manager hosts |
|
|
110
|
+
| closed | `allow_public_package_registries=False` / `Egress.only()` | Snowflake and its stages only |
|
|
92
111
|
| granted | `external_access_integrations=(...)` | the above, plus whatever the EAI's network rules resolve to |
|
|
93
112
|
|
|
94
113
|
**The baseline is not the internet.** It is a curated host set the platform
|
|
@@ -96,17 +115,30 @@ class Egress:
|
|
|
96
115
|
list requires an External Access Integration whichever level you pick. A
|
|
97
116
|
caller-supplied host list used to be the third option and is now retired.
|
|
98
117
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
**The parameter is `allow_public_package_registries`.** Two earlier spellings still work
|
|
119
|
+
and are deprecated — `allow_default_egress` and, before it, `allow_internet`. Setting more
|
|
120
|
+
than one to *different* values raises, since they are one control.
|
|
121
|
+
|
|
122
|
+
Neither older name survived contact with what the field does. `allow_internet` was wrong
|
|
123
|
+
in both directions: it never granted the internet (the baseline is a curated ~43-host set,
|
|
124
|
+
GET/HEAD only), and `False` does not stop egress because an EAI still grants.
|
|
125
|
+
`allow_default_egress` then named the value's *provenance* rather than what it grants, and
|
|
126
|
+
self-contradicts the moment the platform default changes — at which point
|
|
127
|
+
`default_egress=False` would *be* the default. The current name says what is granted.
|
|
128
|
+
|
|
129
|
+
Neither older name emits a DeprecationWarning yet. That is deliberate and not an
|
|
130
|
+
oversight: the warning would fire on construction, so adding it means migrating every
|
|
131
|
+
test and example that still uses an old name, for no behaviour change. It arrives with the
|
|
132
|
+
release that removes them.
|
|
102
133
|
|
|
103
134
|
Example:
|
|
104
135
|
egress = Egress() # baseline
|
|
105
136
|
egress = Egress.only() # closed: nothing arbitrary
|
|
106
|
-
egress = Egress(
|
|
137
|
+
egress = Egress(allow_public_package_registries=False,
|
|
138
|
+
external_access_integrations=("MY_EAI",))
|
|
107
139
|
|
|
108
|
-
`allow_github` and `allow_pypi` are both **deprecated and inert
|
|
109
|
-
|
|
140
|
+
`allow_github` and `allow_pypi` are both **deprecated and inert**; the egress level is the
|
|
141
|
+
only control that does anything. Public PyPI is already reachable via the package-managers
|
|
110
142
|
group, and the six GitHub/dbt hosts now need an External Access Integration like any
|
|
111
143
|
other host outside that group. Both flags will be removed in a future release.
|
|
112
144
|
"""
|
|
@@ -118,8 +150,8 @@ class Egress:
|
|
|
118
150
|
# baseline and disables the group flags below.
|
|
119
151
|
allow_internet: bool | None = None
|
|
120
152
|
# Deprecated and inert. It set EgressConfig.allow_egress_to_github_and_dbt, the six-host
|
|
121
|
-
# GitHub/dbt group, which the platform is removing — leaving
|
|
122
|
-
#
|
|
153
|
+
# GitHub/dbt group, which the platform is removing — leaving the egress level as the only
|
|
154
|
+
# control. Those hosts need an External Access Integration now. Setting it does
|
|
123
155
|
# nothing and, unlike allow_pypi, does not even warn (see __new__); to_wire() no longer
|
|
124
156
|
# sends it. Retained and still defaulting True, so existing callers keep constructing
|
|
125
157
|
# without a TypeError and no value of it reads as meaningful; remove in a later major.
|
|
@@ -139,7 +171,25 @@ class Egress:
|
|
|
139
171
|
# replaces. Appended last so positional Egress(None, True, False, (...)) callers are
|
|
140
172
|
# unaffected. The WIRE key is still allow_internet — this is a client-side rename, so
|
|
141
173
|
# no server change is needed and old and new SDKs interoperate.
|
|
174
|
+
#
|
|
175
|
+
# SUPERSEDED by allow_public_package_registries below. Kept working, not removed: it
|
|
176
|
+
# shipped in v0.2.1a1 / v0.2.2.dev1 / v0.2.2a2 / v0.2.2a3, so deleting it would break
|
|
177
|
+
# callers who adopted it.
|
|
142
178
|
allow_default_egress: bool | None = None
|
|
179
|
+
# The current name, and the one to use. Matches sandbox-api's request field
|
|
180
|
+
# (allow_public_package_registries, sandbox-api#349) and the Snowsight dialog's wording,
|
|
181
|
+
# so the client, the wire contract and the UI finally agree on what this knob is called.
|
|
182
|
+
#
|
|
183
|
+
# Why the two names above are not it. "internet" was wrong in both directions: it never
|
|
184
|
+
# granted the internet (the baseline is ~43 curated hosts, GET/HEAD only) and False does
|
|
185
|
+
# not stop egress, because an EAI still grants. "default egress" then described the
|
|
186
|
+
# value's PROVENANCE rather than what it grants, and self-contradicts the moment the
|
|
187
|
+
# platform default changes — at which point "default_egress=False" would BE the default.
|
|
188
|
+
# This name says what is actually granted: access to public package registries.
|
|
189
|
+
#
|
|
190
|
+
# Appended last so positional Egress(None, True, False, (...), None) callers are
|
|
191
|
+
# unaffected. Tri-state exactly like the fields it supersedes.
|
|
192
|
+
allow_public_package_registries: bool | None = None
|
|
143
193
|
|
|
144
194
|
def __post_init__(self) -> None:
|
|
145
195
|
# A dataclass enforces no types, so Egress(allow_internet="false") kept the string and
|
|
@@ -148,6 +198,7 @@ class Egress:
|
|
|
148
198
|
for name, nullable in (
|
|
149
199
|
("allow_internet", True),
|
|
150
200
|
("allow_default_egress", True),
|
|
201
|
+
("allow_public_package_registries", True),
|
|
151
202
|
("allow_github", False),
|
|
152
203
|
("allow_pypi", False),
|
|
153
204
|
):
|
|
@@ -158,17 +209,21 @@ class Egress:
|
|
|
158
209
|
raise SandboxError(
|
|
159
210
|
f"Egress({name}=) must be True or False, got {type(value).__name__} {value!r}"
|
|
160
211
|
)
|
|
161
|
-
#
|
|
212
|
+
# Any two spellings set and disagreeing is ambiguous, and picking one silently would
|
|
162
213
|
# apply an egress posture the caller did not ask for. Raise at the call instead.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
214
|
+
# Checked pairwise across all THREE names, not just the newest pair: a caller
|
|
215
|
+
# mid-migration can easily have the oldest and newest both set.
|
|
216
|
+
spellings = [
|
|
217
|
+
("allow_public_package_registries", self.allow_public_package_registries),
|
|
218
|
+
("allow_default_egress", self.allow_default_egress),
|
|
219
|
+
("allow_internet", self.allow_internet),
|
|
220
|
+
]
|
|
221
|
+
given = [(n, v) for n, v in spellings if v is not None]
|
|
222
|
+
if len({v for _, v in given}) > 1:
|
|
223
|
+
shown = ", ".join(f"{n}={v!r}" for n, v in given)
|
|
168
224
|
raise SandboxError(
|
|
169
|
-
"Egress
|
|
170
|
-
f"
|
|
171
|
-
"same control — pass only allow_default_egress."
|
|
225
|
+
f"Egress spellings of the egress level disagree ({shown}). They are all the "
|
|
226
|
+
f"same control — pass only {_EGRESS_LEVEL_PARAM}."
|
|
172
227
|
)
|
|
173
228
|
# NOT warned on deliberately. The suite runs filterwarnings=error, so emitting a
|
|
174
229
|
# DeprecationWarning here fails all 21 tests that construct Egress(allow_internet=)
|
|
@@ -194,7 +249,8 @@ class Egress:
|
|
|
194
249
|
def only() -> Egress:
|
|
195
250
|
"""Close egress — ``Egress.only()``, with no arguments.
|
|
196
251
|
|
|
197
|
-
Sets ``
|
|
252
|
+
Sets ``allow_public_package_registries=False``, which withholds the platform baseline as
|
|
253
|
+
well: no
|
|
198
254
|
arbitrary internet host is reachable, not even the package-manager set the default
|
|
199
255
|
mode grants. Snowflake and its stages stay reachable regardless — see the danger
|
|
200
256
|
note below, which is why this is not described as total confinement.
|
|
@@ -203,7 +259,7 @@ class Egress:
|
|
|
203
259
|
caller-supplied host list; to confine and then grant a specific host, pair
|
|
204
260
|
confinement with an External Access Integration:
|
|
205
261
|
|
|
206
|
-
Egress(
|
|
262
|
+
Egress(allow_public_package_registries=False, external_access_integrations=("MY_EAI",))
|
|
207
263
|
|
|
208
264
|
!!! danger "Not an exfiltration boundary for Snowflake destinations"
|
|
209
265
|
``only()`` does **not** confine the sandbox to *just* a chosen set of hosts.
|
|
@@ -220,9 +276,9 @@ class Egress:
|
|
|
220
276
|
restriction. See *Reachability is not credential scope* above and the
|
|
221
277
|
``Secret`` docs.
|
|
222
278
|
"""
|
|
223
|
-
#
|
|
224
|
-
# caller who never
|
|
225
|
-
return Egress(
|
|
279
|
+
# Current spelling deliberately: only() must not attribute a deprecated name to a
|
|
280
|
+
# caller who never typed one.
|
|
281
|
+
return Egress(allow_public_package_registries=False)
|
|
226
282
|
|
|
227
283
|
@property
|
|
228
284
|
def default_egress_allowed(self) -> bool | None:
|
|
@@ -232,6 +288,8 @@ class Egress:
|
|
|
232
288
|
look "unset" to logic that checks the old one, which is the bug a plain alias
|
|
233
289
|
would introduce.
|
|
234
290
|
"""
|
|
291
|
+
if self.allow_public_package_registries is not None:
|
|
292
|
+
return self.allow_public_package_registries
|
|
235
293
|
if self.allow_default_egress is not None:
|
|
236
294
|
return self.allow_default_egress
|
|
237
295
|
return self.allow_internet
|
|
@@ -246,7 +304,11 @@ class Egress:
|
|
|
246
304
|
if effective is not None:
|
|
247
305
|
# Sent even when False: the platform reads an absent key as its permissive
|
|
248
306
|
# default, so omitting it would silently discard a request to close egress.
|
|
249
|
-
#
|
|
307
|
+
#
|
|
308
|
+
# EXACTLY ONE spelling goes on the wire. sandbox-api refuses a request carrying
|
|
309
|
+
# two spellings that disagree (containers/egress.go), so a client that sent both
|
|
310
|
+
# could produce a refusal that reads as a server bug. See
|
|
311
|
+
# _DEFAULT_EGRESS_WIRE_KEY for why that one spelling is still allow_internet.
|
|
250
312
|
body[_DEFAULT_EGRESS_WIRE_KEY] = effective
|
|
251
313
|
# Neither group flag is sent: the platform is removing the GitHub/dbt flag and already
|
|
252
314
|
# reserved the one allow_pypi fed, so either would describe a grant that never happens.
|
|
@@ -302,8 +364,10 @@ def compile_egress(
|
|
|
302
364
|
the two now coincide: the only meaningful field is the default-egress tri-state, whose
|
|
303
365
|
default is the platform's own, so a default `Egress` serialises to nothing. A dict is
|
|
304
366
|
taken as authored, since that path is the raw-wire escape hatch — except that
|
|
305
|
-
``
|
|
306
|
-
|
|
367
|
+
``allow_public_package_registries`` and the older ``allow_default_egress`` are both
|
|
368
|
+
translated to the single wire key this client sends, because a raw-dict caller who used
|
|
369
|
+
the name the docs teach would otherwise have it silently ignored — and because sandbox-api
|
|
370
|
+
refuses a request that carries two disagreeing spellings of the same control.
|
|
307
371
|
"""
|
|
308
372
|
if isinstance(egress, Egress):
|
|
309
373
|
body = egress.to_wire()
|
|
@@ -312,18 +376,28 @@ def compile_egress(
|
|
|
312
376
|
else:
|
|
313
377
|
body = dict(egress)
|
|
314
378
|
# A raw dict is otherwise passed through verbatim, which would make
|
|
315
|
-
# {"
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
379
|
+
# {"allow_public_package_registries": False} or {"allow_default_egress": False} a
|
|
380
|
+
# silent no-op ON A CLIENT PATH THAT SENDS THE OLD KEY: the server would see no
|
|
381
|
+
# allow_internet key and apply its PERMISSIVE default, so a caller asking to close
|
|
382
|
+
# egress would get the baseline instead. Translate to the one wire key this client
|
|
383
|
+
# sends, rather than refuse, matching what the typed path accepts.
|
|
384
|
+
#
|
|
385
|
+
# EXACTLY ONE spelling may survive. sandbox-api refuses a request carrying two
|
|
386
|
+
# spellings that disagree, so collapsing them here turns a confusing server refusal
|
|
387
|
+
# into a local error naming the caller's own keys. Note allow_public_package_registries
|
|
388
|
+
# is BOTH a client name and a real wire key the server accepts — it is still collapsed
|
|
389
|
+
# onto _DEFAULT_EGRESS_WIRE_KEY so that one client cannot emit two spellings at once.
|
|
390
|
+
_level_aliases = ("allow_public_package_registries", "allow_default_egress")
|
|
391
|
+
for _alias in _level_aliases:
|
|
392
|
+
if _alias not in body:
|
|
393
|
+
continue
|
|
394
|
+
renamed = body.pop(_alias)
|
|
321
395
|
existing = body.get(_DEFAULT_EGRESS_WIRE_KEY)
|
|
322
396
|
if existing is not None and renamed is not None and existing != renamed:
|
|
323
397
|
raise SandboxError(
|
|
324
|
-
f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and "
|
|
325
|
-
f"
|
|
326
|
-
f"{
|
|
398
|
+
f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and {_alias!r} to "
|
|
399
|
+
f"different values ({existing!r} vs {renamed!r}). They are the same "
|
|
400
|
+
f"control — pass one, and {_EGRESS_LEVEL_PARAM!r} is the current name."
|
|
327
401
|
)
|
|
328
402
|
if renamed is not None:
|
|
329
403
|
body[_DEFAULT_EGRESS_WIRE_KEY] = renamed
|
|
@@ -149,7 +149,7 @@ def _raise_if_stream_terminated(abort: StreamAbort) -> None:
|
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
class Sandbox(_FilesMixin, _SandboxState):
|
|
152
|
-
"""A
|
|
152
|
+
"""A Cortex Sandbox (sync API).
|
|
153
153
|
|
|
154
154
|
`create()` is the factory, and the only creation path -- ``Sandbox(...)`` raises.
|
|
155
155
|
Reconnect to an existing sandbox with ``Sandbox.connect(name)`` / `get_sandbox` /
|
|
@@ -768,12 +768,26 @@ class Sandbox(_FilesMixin, _SandboxState):
|
|
|
768
768
|
return
|
|
769
769
|
self._wait_until_ready(timeout=timeout)
|
|
770
770
|
|
|
771
|
-
def logs(
|
|
771
|
+
def logs(
|
|
772
|
+
self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
|
|
773
|
+
) -> str:
|
|
772
774
|
"""Return the container's captured stdout and stderr as a string.
|
|
773
775
|
|
|
774
776
|
`tail` limits to the last N lines. Only meaningful for command containers
|
|
775
777
|
(created with `command=`).
|
|
776
778
|
|
|
779
|
+
`source` selects which stream to read, defaulting to the server's `all` -- the
|
|
780
|
+
entrypoint plus the `command=` process:
|
|
781
|
+
|
|
782
|
+
- ``"exec"`` -- what the commands you ran with `exec()` printed, which no other
|
|
783
|
+
source includes. `exec()` also returns that output directly; this is how you
|
|
784
|
+
read it again afterwards.
|
|
785
|
+
- ``"base"`` / ``"managed"`` -- one of the two long-lived streams on its own.
|
|
786
|
+
|
|
787
|
+
`pid` narrows an exec read to one producer. Without it every exec is merged and
|
|
788
|
+
the returned text carries no per-line attribution; `pid=` is how you read one
|
|
789
|
+
command's output on its own.
|
|
790
|
+
|
|
777
791
|
SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
|
|
778
792
|
run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
|
|
779
793
|
reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
|
|
@@ -784,10 +798,13 @@ class Sandbox(_FilesMixin, _SandboxState):
|
|
|
784
798
|
Example:
|
|
785
799
|
output = sb.logs(tail=100)
|
|
786
800
|
print(output)
|
|
801
|
+
|
|
802
|
+
# What the commands you ran with `exec()` printed.
|
|
803
|
+
print(sb.logs(source="exec"))
|
|
787
804
|
"""
|
|
788
805
|
fetch = _logs_overfetch(tail)
|
|
789
806
|
while True:
|
|
790
|
-
raw = self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms)
|
|
807
|
+
raw = self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms, source=source, pid=pid)
|
|
791
808
|
raw_count = len(raw.split("\n")) if raw else 0
|
|
792
809
|
visible = _visible_log_lines(raw)
|
|
793
810
|
if tail <= 0 or len(visible) >= tail:
|
|
@@ -805,7 +822,9 @@ class Sandbox(_FilesMixin, _SandboxState):
|
|
|
805
822
|
visible = visible[-tail:]
|
|
806
823
|
return "\n".join(visible)
|
|
807
824
|
|
|
808
|
-
def _logs_raw(
|
|
825
|
+
def _logs_raw(
|
|
826
|
+
self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
|
|
827
|
+
) -> str:
|
|
809
828
|
"""The log snapshot EXACTLY as the server returns it, markers included.
|
|
810
829
|
|
|
811
830
|
Internal consumers that read the runner's or daemon's stdout protocol
|
|
@@ -824,6 +843,14 @@ class Sandbox(_FilesMixin, _SandboxState):
|
|
|
824
843
|
q.append(f"tail={tail}")
|
|
825
844
|
if since_ts_ms:
|
|
826
845
|
q.append(f"since={since_ts_ms}")
|
|
846
|
+
# Forwarded verbatim rather than validated here: the server owns the vocabulary
|
|
847
|
+
# and names the valid values in its rejection, so a client-side allowlist would go
|
|
848
|
+
# stale as soon as a source is added and would turn a 400 that explains itself
|
|
849
|
+
# into one that does not.
|
|
850
|
+
if source:
|
|
851
|
+
q.append(f"source={source}")
|
|
852
|
+
if pid:
|
|
853
|
+
q.append(f"pid={pid}")
|
|
827
854
|
path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
|
|
828
855
|
try:
|
|
829
856
|
resp = self._transport.request("GET", path, retry_500_delays=DEFAULT_LOGS_500_DELAYS_S)
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/tests/test_docs_references.py
RENAMED
|
@@ -563,3 +563,289 @@ def test_surface_table_excludes_error_page_types_by_type_not_by_name() -> None:
|
|
|
563
563
|
assert f"`{name}`" not in section, (
|
|
564
564
|
f"{name} is covered by the error page but also appears in the surface table"
|
|
565
565
|
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# Content gate: the class of defect `--check` structurally cannot see
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
#
|
|
572
|
+
# The drift gate compares a committed page against fresh generator output,
|
|
573
|
+
# SHA-normalised. That proves the two agree; it does not prove either is right.
|
|
574
|
+
# Prose hardcoded inside a generator is therefore checked against its own output
|
|
575
|
+
# and always passes -- and 41% of generate_docs_reference.py and 16% of
|
|
576
|
+
# generate_cli_reference.py is hardcoded prose.
|
|
577
|
+
#
|
|
578
|
+
# Five strings reached customer-facing pages that way, each found by reading:
|
|
579
|
+
#
|
|
580
|
+
# prod_snowhouse as the example connection name (fixed in #431)
|
|
581
|
+
# sandbox-api#337/#226 cited in an idle_suspend docstring (fixed in #432)
|
|
582
|
+
# SMASON / ENGINEER as sample output (already pinned below)
|
|
583
|
+
# RELATED_TOPICS grown to 16 entries (fixed in #433/#434)
|
|
584
|
+
# "a Snowflake sandbox", a product that does not exist (fixed in #444)
|
|
585
|
+
#
|
|
586
|
+
# These assertions run over the EMITTED pages rather than generator source, which
|
|
587
|
+
# is the point: two of those five originated in docstrings and three in generator
|
|
588
|
+
# literals, and a check on either source alone would miss the other. Moving a
|
|
589
|
+
# string between a docstring and a generator cannot evade this.
|
|
590
|
+
|
|
591
|
+
_GENERATED_PAGES = (
|
|
592
|
+
"docs/generated/sdk-reference.mdx",
|
|
593
|
+
"docs/generated/cli-reference.mdx",
|
|
594
|
+
"docs/generated/error-reference.mdx",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# (regex, why it must never appear). Matched case-insensitively.
|
|
598
|
+
#
|
|
599
|
+
# Every pattern is a literal or an explicitly escaped regex. An unescaped dot is
|
|
600
|
+
# not a cosmetic slip here: `snowflake.sandbox` matches 13 strings on these pages
|
|
601
|
+
# where `snowflake\.sandbox` matches 9, because the extra four are
|
|
602
|
+
# `snowflake-sandbox` in the DO-NOT-EDIT banner of every page. A pattern written
|
|
603
|
+
# that way fails on a clean tree.
|
|
604
|
+
_BANNED_IN_GENERATED = (
|
|
605
|
+
(
|
|
606
|
+
r"snowflake sandbox(?:es)?\b",
|
|
607
|
+
'the product is "Cortex Sandboxes"; "Snowflake Sandboxes" is only the reserved '
|
|
608
|
+
"public-preview fallback and must not be published before that decision is taken",
|
|
609
|
+
),
|
|
610
|
+
(
|
|
611
|
+
r"cortex cloud sandboxes",
|
|
612
|
+
'a name explicitly rejected in the naming decision -- "cloud sandbox" is CoCo\'s, '
|
|
613
|
+
"so the qualifier recreates the collision it was meant to remove",
|
|
614
|
+
),
|
|
615
|
+
(
|
|
616
|
+
r"snowhouse",
|
|
617
|
+
"Snowhouse is Snowflake's internal production account; it must never appear as a "
|
|
618
|
+
'customer-facing example (use "prod_account" or similar)',
|
|
619
|
+
),
|
|
620
|
+
(
|
|
621
|
+
r"sandbox-(?:api|sdk)#\d+",
|
|
622
|
+
"internal issue references do not belong in customer prose -- state the behaviour, "
|
|
623
|
+
"or leave it out until it is settled",
|
|
624
|
+
),
|
|
625
|
+
(
|
|
626
|
+
r"\bsmason\b",
|
|
627
|
+
"a real username -- sample output must use a placeholder such as MY_USER",
|
|
628
|
+
),
|
|
629
|
+
(
|
|
630
|
+
r"\bengineer\b",
|
|
631
|
+
"a real role name from a developer's account -- sample output must use a "
|
|
632
|
+
"placeholder such as MY_ROLE",
|
|
633
|
+
),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _generated_pages() -> list[tuple[str, str]]:
|
|
638
|
+
root = Path(__file__).resolve().parent.parent
|
|
639
|
+
pages = []
|
|
640
|
+
for rel in _GENERATED_PAGES:
|
|
641
|
+
path = root / rel
|
|
642
|
+
assert path.is_file(), (
|
|
643
|
+
f"{rel} not found at {path}. If the generated pages moved, update "
|
|
644
|
+
"_GENERATED_PAGES -- otherwise every content check below passes vacuously."
|
|
645
|
+
)
|
|
646
|
+
pages.append((rel, path.read_text(encoding="utf-8")))
|
|
647
|
+
return pages
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def test_the_generated_pages_are_present_and_substantial() -> None:
|
|
651
|
+
"""Guard the guard, the same way `test_docs_directory_is_present` does.
|
|
652
|
+
|
|
653
|
+
A content assertion that reads an empty or missing file reports success. That is
|
|
654
|
+
the worse failure than a wrong string, because it is silent: the gate goes on
|
|
655
|
+
passing while checking nothing. So establish the corpus exists and is real
|
|
656
|
+
before anything below relies on it.
|
|
657
|
+
"""
|
|
658
|
+
pages = _generated_pages()
|
|
659
|
+
assert len(pages) == 3, "expected three generated pages"
|
|
660
|
+
for rel, text in pages:
|
|
661
|
+
assert len(text) > 2_000, (
|
|
662
|
+
f"{rel} is only {len(text)} bytes -- too short to be a real reference page. "
|
|
663
|
+
"Either generation failed or the path is wrong; either way the checks below "
|
|
664
|
+
"are not checking anything."
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def test_generated_pages_carry_no_banned_string() -> None:
|
|
669
|
+
"""Wrong names, internal identifiers and personal information, all previously shipped.
|
|
670
|
+
|
|
671
|
+
Each pattern here corresponds to a string that actually reached a customer-facing
|
|
672
|
+
page, not a hypothetical. The drift gate passed every one of them.
|
|
673
|
+
"""
|
|
674
|
+
offenders: list[str] = []
|
|
675
|
+
for rel, text in _generated_pages():
|
|
676
|
+
for pattern, reason in _BANNED_IN_GENERATED:
|
|
677
|
+
for hit in set(re.findall(pattern, text, re.IGNORECASE)):
|
|
678
|
+
offenders.append(f" {rel}: {hit!r}\n {reason}")
|
|
679
|
+
|
|
680
|
+
assert not offenders, "banned strings in generated pages:\n" + "\n".join(sorted(offenders))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def test_the_banned_patterns_spare_the_protected_identifiers() -> None:
|
|
684
|
+
"""The distribution name, the import path and the repo name all contain "sandbox".
|
|
685
|
+
|
|
686
|
+
Docs paths and prose carry the product name; code identifiers never do. So a
|
|
687
|
+
banned-string pattern that is slightly too greedy breaks the build on a clean
|
|
688
|
+
tree rather than catching anything -- `snowflake-sandbox-sdk@<sha>` sits in the
|
|
689
|
+
DO-NOT-EDIT banner of all three pages, so it is the first thing an over-broad
|
|
690
|
+
pattern hits.
|
|
691
|
+
|
|
692
|
+
This asserts the gate above is compatible with those three identifiers, using
|
|
693
|
+
strings taken from the real pages rather than invented ones.
|
|
694
|
+
"""
|
|
695
|
+
protected = (
|
|
696
|
+
"snowflake-sandbox-python",
|
|
697
|
+
"snowflake.sandbox",
|
|
698
|
+
"{/* DO NOT EDIT — generated from snowflake-sandbox-sdk@abc1234.",
|
|
699
|
+
"from snowflake.sandbox import Sandbox",
|
|
700
|
+
"pip install snowflake-sandbox-python",
|
|
701
|
+
)
|
|
702
|
+
for sample in protected:
|
|
703
|
+
for pattern, _ in _BANNED_IN_GENERATED:
|
|
704
|
+
assert not re.search(pattern, sample, re.IGNORECASE), (
|
|
705
|
+
f"pattern {pattern!r} matches the protected identifier {sample!r}. "
|
|
706
|
+
"Tighten the pattern -- an unescaped dot or a missing word boundary will "
|
|
707
|
+
"fire on every page's banner."
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# The Cortex Sandboxes doc set, in reading order: overview, the two entry paths,
|
|
712
|
+
# conceptual grounding, tutorials, then the references and support pages. Every page's
|
|
713
|
+
# Related Content is this list minus its own slug, so the right-hand pane is identical
|
|
714
|
+
# and complete across the set.
|
|
715
|
+
#
|
|
716
|
+
# The eight individual tutorials are deliberately absent. They are represented by their
|
|
717
|
+
# landing page, `overview-tutorials`, whose body already indexes all eight. A page that
|
|
718
|
+
# links one tutorial and not its siblings is making an editorial claim nobody made.
|
|
719
|
+
_CANONICAL_RELATED_TOPICS = (
|
|
720
|
+
"overview",
|
|
721
|
+
"sdk-quickstart",
|
|
722
|
+
"cli-quickstart",
|
|
723
|
+
"concepts-and-security",
|
|
724
|
+
"connections",
|
|
725
|
+
"overview-tutorials",
|
|
726
|
+
"sdk-reference",
|
|
727
|
+
"cli-reference",
|
|
728
|
+
"error-reference",
|
|
729
|
+
"troubleshooting",
|
|
730
|
+
"limitations-and-faq",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
_OWN_SLUG = {
|
|
734
|
+
"docs/generated/sdk-reference.mdx": "sdk-reference",
|
|
735
|
+
"docs/generated/cli-reference.mdx": "cli-reference",
|
|
736
|
+
"docs/generated/error-reference.mdx": "error-reference",
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def test_related_topics_matches_the_canonical_list_exactly() -> None:
|
|
741
|
+
"""Related Content must be the canonical doc set minus the page's own slug.
|
|
742
|
+
|
|
743
|
+
This replaces a `3 <= len(entries) <= 7` range check. The range was added after
|
|
744
|
+
Related Content ballooned to 16 entries and a blind copy then reverted the fix, so
|
|
745
|
+
it was built to catch *growth* -- and it did. What it could not see was omission.
|
|
746
|
+
Under it, the three generated pages carried bespoke 5-7 entry lists that between
|
|
747
|
+
them were missing 14 canonical links, and every page but one omitted the tutorials
|
|
748
|
+
landing page while linking a single individual tutorial instead. All of it passed.
|
|
749
|
+
|
|
750
|
+
So assert the exact sequence. That catches four failure modes the range could not:
|
|
751
|
+
a missing page, an extra one, an individual-tutorial link, and a reordering.
|
|
752
|
+
|
|
753
|
+
Pinning is deliberate here, and is the opposite of the choice made in
|
|
754
|
+
`test_the_egress_key_fields_table_names_only_real_parameters` a few lines down --
|
|
755
|
+
worth explaining, because the two look contradictory. The egress table documents an
|
|
756
|
+
evolving API, where the set of parameter names legitimately changes and pinning
|
|
757
|
+
would fight ordinary development; the invariant there is only that every documented
|
|
758
|
+
name still exists. This list is not evolving incidentally: it *is* the spec. Adding
|
|
759
|
+
a page to the doc set is a deliberate act that should update this tuple once, in one
|
|
760
|
+
place, and fail every page that has not caught up.
|
|
761
|
+
|
|
762
|
+
Covers the three generated pages only, since they are the ones this repo owns. The
|
|
763
|
+
16 hand-authored pages live in snowflake-prod-docs and are not reachable from here.
|
|
764
|
+
"""
|
|
765
|
+
for rel, text in _generated_pages():
|
|
766
|
+
own = _OWN_SLUG.get(rel)
|
|
767
|
+
assert own is not None, (
|
|
768
|
+
f"{rel} has no entry in _OWN_SLUG. A generated page was added without "
|
|
769
|
+
"recording which slug it must omit from its own Related Content."
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
block = text.split("<RelatedTopics>", 1)
|
|
773
|
+
assert len(block) == 2, f"{rel} has no <RelatedTopics> block"
|
|
774
|
+
entries = [
|
|
775
|
+
line
|
|
776
|
+
for line in block[1].split("</RelatedTopics>", 1)[0].splitlines()
|
|
777
|
+
if line.startswith("- [")
|
|
778
|
+
]
|
|
779
|
+
assert entries, (
|
|
780
|
+
f"{rel} has a <RelatedTopics> block with no `- [` entries -- the block shape "
|
|
781
|
+
"changed, and this check is now passing without checking anything"
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
slugs = []
|
|
785
|
+
for line in entries:
|
|
786
|
+
match = re.search(
|
|
787
|
+
r"\]\(/LIMITEDACCESS/developer-guide/cortex-sandboxes/([^)]+)\)", line
|
|
788
|
+
)
|
|
789
|
+
assert match is not None, (
|
|
790
|
+
f"{rel}: could not parse a docs slug out of {line!r}. Related Content "
|
|
791
|
+
"links must be absolute under the Cortex Sandboxes base path."
|
|
792
|
+
)
|
|
793
|
+
slugs.append(match.group(1))
|
|
794
|
+
|
|
795
|
+
expected = [slug for slug in _CANONICAL_RELATED_TOPICS if slug != own]
|
|
796
|
+
assert slugs == expected, (
|
|
797
|
+
f"{rel} Related Content does not match the canonical list.\n"
|
|
798
|
+
f" missing: {sorted(set(expected) - set(slugs))}\n"
|
|
799
|
+
f" unexpected:{sorted(set(slugs) - set(expected))}\n"
|
|
800
|
+
f" got order: {slugs}\n"
|
|
801
|
+
f" expected: {expected}\n"
|
|
802
|
+
"Fix the RELATED_TOPICS block in the generator, not the page. If a page was "
|
|
803
|
+
"genuinely added to or removed from the doc set, update "
|
|
804
|
+
"_CANONICAL_RELATED_TOPICS -- every page then updates with it."
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def test_the_egress_key_fields_table_names_only_real_parameters() -> None:
|
|
809
|
+
"""The egress table is hardcoded in the generator, so a rename can orphan it silently.
|
|
810
|
+
|
|
811
|
+
This already happened: `test_common_operations_table_names_only_real_api` was added
|
|
812
|
+
because "the egress section came to assert a parameter that had been renamed" (#392),
|
|
813
|
+
and the same section was rewritten again when `allow_default_egress` became
|
|
814
|
+
`allow_public_package_registries` (#445).
|
|
815
|
+
|
|
816
|
+
Neither the drift gate nor a banned-string list can catch that. The gate compares the
|
|
817
|
+
generator against its own output, and a stale parameter name is not a banned string --
|
|
818
|
+
it is a name that simply no longer exists. So check the table against the dataclass.
|
|
819
|
+
|
|
820
|
+
Deliberately not a list of expected names. Pinning the contents would fail on every
|
|
821
|
+
ordinary edit, and the failure being guarded against is a name that has stopped
|
|
822
|
+
existing, not a set that has changed size.
|
|
823
|
+
"""
|
|
824
|
+
import dataclasses
|
|
825
|
+
|
|
826
|
+
from snowflake.sandbox import Egress
|
|
827
|
+
|
|
828
|
+
page = (Path(__file__).resolve().parent.parent / "docs/generated/sdk-reference.mdx").read_text()
|
|
829
|
+
|
|
830
|
+
assert "### Egress" in page, "the Egress section is gone from the reference"
|
|
831
|
+
section = page.split("### Egress", 1)[1].split("\n### ", 1)[0]
|
|
832
|
+
assert "Key fields:" in section, (
|
|
833
|
+
"the Egress section no longer has a Key fields table -- if it moved, update this "
|
|
834
|
+
"test, because otherwise nothing checks those parameter names against the dataclass"
|
|
835
|
+
)
|
|
836
|
+
table = section.split("Key fields:", 1)[1]
|
|
837
|
+
|
|
838
|
+
documented = {m.group(1) for m in re.finditer(r"^\|\s*`([a-z_]+)`\s*\|", table, re.MULTILINE)}
|
|
839
|
+
assert documented, (
|
|
840
|
+
"no parameter rows parsed out of the Key fields table -- the table shape changed, "
|
|
841
|
+
"and this check is now passing without checking anything"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
real = {f.name for f in dataclasses.fields(Egress)}
|
|
845
|
+
orphaned = sorted(documented - real)
|
|
846
|
+
assert not orphaned, (
|
|
847
|
+
f"the egress Key fields table documents {orphaned}, which are not fields on Egress. "
|
|
848
|
+
"A parameter was renamed or removed and the hardcoded table in "
|
|
849
|
+
"generate_docs_reference.py was not updated with it -- the exact failure that "
|
|
850
|
+
"produced #392 and #445."
|
|
851
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_ansi.py
RENAMED
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_args.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_env.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_sse.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/_wire.py
RENAMED
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/files.py
RENAMED
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/image.py
RENAMED
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/jobs.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/mcp.py
RENAMED
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/mount.py
RENAMED
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/shell.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/src/snowflake/sandbox/types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowflake_sandbox_python-0.2.2a3 → snowflake_sandbox_python-0.2.2a4}/tests/test_smoke_offline.py
RENAMED
|
File without changes
|