From e2903929c9ea9fea18d36b080c09fb783065e43b Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Wed, 15 Jul 2026 18:06:53 -0500 Subject: [PATCH 1/6] feat(streamlit): resolve STREAMLIT grants at plan time (fetch_streamlit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STREAMLIT was a supported schema-scoped resource for rendering grant SQL, but `snowcap plan` errored when a grant referenced a streamlit object: module 'snowcap.data_provider' has no attribute 'fetch_streamlit' Because Streamlit has a concrete resource class (unlike CORTEX SEARCH SERVICE, which is SchemaScope-only), resolving a `on: streamlit ..` grant reference drives fetch_resource -> fetch_streamlit, which didn't exist. This adds it so grants on streamlit apps can be planned and applied: grants: - priv: USAGE on: streamlit .. to: - snowcap/data_provider.py: fetch_streamlit() — SHOW STREAMLITS + DESC STREAMLIT, mapping root_location -> from_, plus main_file / title / query_warehouse / comment / owner (mirrors fetch_notebook). - tests/test_grant.py: test_grant_on_streamlit (USAGE renders correctly). - tests/test_privs.py: TestStreamlitPriv (enum values). - docs/resources/streamlit.md: "Granting access" section. - docs/resources/grant.md: streamlit example in the Object Grants block. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016L3b4GTh5nyFy5vxQ6e9TY --- docs/resources/grant.md | 5 +++++ docs/resources/streamlit.md | 40 ++++++++++++++++++++++++++++++++++++- snowcap/data_provider.py | 24 ++++++++++++++++++++++ tests/test_grant.py | 18 +++++++++++++++++ tests/test_privs.py | 19 ++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 81dad6d..25b3c71 100644 --- a/docs/resources/grant.md +++ b/docs/resources/grant.md @@ -45,6 +45,11 @@ grants: on: warehouse some_warehouse to: some_role + # Apps: USAGE on a Streamlit app so a role can open and run it + - priv: USAGE + on: streamlit somedb.someschema.some_streamlit + to: app_viewer_role + # AI: account-level privilege for Cortex AI SQL functions - priv: USE AI FUNCTIONS on: ACCOUNT diff --git a/docs/resources/streamlit.md b/docs/resources/streamlit.md index 799f09a..78c133d 100644 --- a/docs/resources/streamlit.md +++ b/docs/resources/streamlit.md @@ -69,4 +69,42 @@ streamlit_repo = Streamlit( * `query_warehouse` (string) - The name of the warehouse to use for queries in the app. * `comment` (string) - A comment or description for the Streamlit app. * `owner` (string or Role) - The role that owns the Streamlit app. Defaults to "SYSADMIN". -* `tags` (dict) - A dictionary of tags to associate with the Streamlit app. \ No newline at end of file +* `tags` (dict) - A dictionary of tags to associate with the Streamlit app. + +## Granting access + +A Streamlit app is a schema-scoped object. Grant `USAGE` on the app to let a +role open and run it — this is the whole access story for viewers when the app +uses owner's rights (all app queries execute as the app owner, so viewers need +no privileges on the underlying tables): + +```yaml +grants: + # Let a viewer role open and run the app. + - priv: USAGE + on: streamlit my_db.my_schema.my_streamlit + to: app_viewer_role + + # Schema-scope privilege to allow a role to create Streamlit apps. + - priv: CREATE STREAMLIT + on: schema my_db.my_schema + to: app_developer_role +``` + +```python +# Grant USAGE so a role can open the app +grant = Grant( + priv="USAGE", + on_streamlit="my_db.my_schema.my_streamlit", + to="app_viewer_role", +) +``` + +| Privilege | Purpose | +|-------------------|--------------------------------------------------------------------------| +| `USAGE` | Open, view, and run the Streamlit app (and `DESCRIBE` it). | +| `OWNERSHIP` | Full control. Set at create/deploy time — Snowflake does not support transferring streamlit ownership via `GRANT OWNERSHIP`. | +| `ALL` | All privileges above. | + +The schema-scope privilege `CREATE STREAMLIT` lets a role create apps in that +schema; creating an app with a `ROOT_LOCATION` stage also needs `CREATE STAGE`. \ No newline at end of file diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..b7a5671 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -2773,6 +2773,30 @@ def fetch_stream(session: SnowflakeConnection, fqn: FQN): raise NotImplementedError(f"Unsupported stream source type {data['source_type']}") +def fetch_streamlit(session: SnowflakeConnection, fqn: FQN): + streamlits = _show_resources(session, "STREAMLITS", fqn) + if len(streamlits) == 0: + return None + if len(streamlits) > 1: + raise Exception(f"Found multiple streamlits matching {fqn}") + + data = streamlits[0] + desc_result = execute(session, f"DESC STREAMLIT {fqn}", cacheable=True) + properties = desc_result[0] + return { + "name": data["name"], + # root_location is the stage the app was deployed from, e.g. + # '@db.schema.stage/app' — this is the `from_` field. + "from_": properties.get("root_location"), + "version": None, + "main_file": properties.get("main_file"), + "title": properties.get("title") or None, + "query_warehouse": data.get("query_warehouse") or None, + "comment": data.get("comment") or None, + "owner": _get_owner_identifier(data), + } + + def fetch_tag(session: SnowflakeConnection, fqn: FQN): try: show_result = execute(session, "SHOW TAGS IN ACCOUNT", cacheable=True) diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..e30a4cf 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -174,6 +174,24 @@ def test_grant_on_cortex_search_service(): assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql() +def test_grant_on_streamlit(): + """USAGE on a STREAMLIT parses and renders correctly. + + Streamlit is a schema-scoped app object. Granting USAGE lets a viewer + role open and run the app: + GRANT USAGE ON STREAMLIT .. TO ROLE r + OWNERSHIP is established at create/deploy time, not transferred via GRANT. + """ + grant = res.Grant( + priv="USAGE", + on_streamlit="somedb.someschema.someapp", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SOMESCHEMA.SOMEAPP" + assert grant._data.on_type == ResourceType.STREAMLIT + assert "USAGE ON STREAMLIT" in grant.create_sql() + + def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) diff --git a/tests/test_privs.py b/tests/test_privs.py index 6450721..84ff639 100644 --- a/tests/test_privs.py +++ b/tests/test_privs.py @@ -347,6 +347,25 @@ def test_ownership_privilege(self): assert CortexSearchServicePriv.OWNERSHIP.value == "OWNERSHIP" +############################################################################# +# StreamlitPriv Tests +############################################################################# + + +class TestStreamlitPriv: + """Tests for StreamlitPriv enum values (Snowflake Streamlit apps).""" + + def test_all_privilege(self): + assert StreamlitPriv.ALL.value == "ALL" + + def test_usage_privilege(self): + """USAGE is the priv needed to open and run a Streamlit app.""" + assert StreamlitPriv.USAGE.value == "USAGE" + + def test_ownership_privilege(self): + assert StreamlitPriv.OWNERSHIP.value == "OWNERSHIP" + + ############################################################################# # is_ownership_priv() Tests ############################################################################# From c820c80e344386d4cfaf42a592dd23be0ec3191d Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Tue, 21 Jul 2026 11:46:17 -0500 Subject: [PATCH 2/6] fix(streamlit): drop from_ from fetch, map default main_file, note version limitation --- docs/resources/streamlit.md | 2 +- snowcap/data_provider.py | 15 +++++++++++---- snowcap/resources/streamlit.py | 7 +++++-- uv.lock | 3 +++ 4 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 uv.lock diff --git a/docs/resources/streamlit.md b/docs/resources/streamlit.md index 78c133d..f657ef6 100644 --- a/docs/resources/streamlit.md +++ b/docs/resources/streamlit.md @@ -107,4 +107,4 @@ grant = Grant( | `ALL` | All privileges above. | The schema-scope privilege `CREATE STREAMLIT` lets a role create apps in that -schema; creating an app with a `ROOT_LOCATION` stage also needs `CREATE STAGE`. \ No newline at end of file +schema; creating an app with a `ROOT_LOCATION` stage also needs `CREATE STAGE`. diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index b7a5671..0e250e7 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -2785,11 +2785,18 @@ def fetch_streamlit(session: SnowflakeConnection, fqn: FQN): properties = desc_result[0] return { "name": data["name"], - # root_location is the stage the app was deployed from, e.g. - # '@db.schema.stage/app' — this is the `from_` field. - "from_": properties.get("root_location"), + # from_ is omitted (like fetch_notebook): DESC STREAMLIT returns + # root_location as a fully-qualified, uppercased stage path, which + # never matches the declared from_ (e.g. "@my_stage") and would show + # perpetual drift. The spec marks from_ non-fetchable instead. + # version is only settable for repo-based apps and isn't returned by + # DESC STREAMLIT in a comparable form, so it's always None here — + # repo-based apps (from_="https://...", version="main") may drift on + # version. Known limitation. "version": None, - "main_file": properties.get("main_file"), + # Snowflake's default main_file is streamlit_app.py; map it to None so + # an omitted field doesn't drift (same as fetch_notebook). + "main_file": None if properties.get("main_file") == "streamlit_app.py" else properties.get("main_file"), "title": properties.get("title") or None, "query_warehouse": data.get("query_warehouse") or None, "comment": data.get("comment") or None, diff --git a/snowcap/resources/streamlit.py b/snowcap/resources/streamlit.py index 1685bc6..960b57a 100644 --- a/snowcap/resources/streamlit.py +++ b/snowcap/resources/streamlit.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional from ..enums import ResourceType @@ -17,7 +17,10 @@ class _Streamlit(ResourceSpec): """ name: ResourceName - from_: str + # DESC STREAMLIT returns root_location as a fully-qualified, uppercased + # stage path that never matches the declared from_ (e.g. "@my_stage"), so + # from_ is not fetchable — YAML is authoritative (same as Notebook). + from_: str = field(metadata={"fetchable": False}) version: Optional[str] = None main_file: Optional[str] = None title: Optional[str] = None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bda0207 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" From 438214077a7dbe7c2c63d9340f2de0b64c481b3e Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Tue, 21 Jul 2026 11:46:40 -0500 Subject: [PATCH 3/6] chore: remove accidentally committed uv.lock --- uv.lock | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 uv.lock diff --git a/uv.lock b/uv.lock deleted file mode 100644 index bda0207..0000000 --- a/uv.lock +++ /dev/null @@ -1,3 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" From 4e7fa8849d3cf8c0dff2bc2670da96fa066cc5e8 Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Tue, 21 Jul 2026 12:04:11 -0500 Subject: [PATCH 4/6] fix(streamlit): rename init to __init__, default from_ to None for unfetchable field --- snowcap/resources/streamlit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/snowcap/resources/streamlit.py b/snowcap/resources/streamlit.py index 960b57a..bd6416a 100644 --- a/snowcap/resources/streamlit.py +++ b/snowcap/resources/streamlit.py @@ -20,7 +20,7 @@ class _Streamlit(ResourceSpec): # DESC STREAMLIT returns root_location as a fully-qualified, uppercased # stage path that never matches the declared from_ (e.g. "@my_stage"), so # from_ is not fetchable — YAML is authoritative (same as Notebook). - from_: str = field(metadata={"fetchable": False}) + from_: str = field(default=None, metadata={"fetchable": False}) version: Optional[str] = None main_file: Optional[str] = None title: Optional[str] = None @@ -30,7 +30,7 @@ class _Streamlit(ResourceSpec): def __post_init__(self): super().__post_init__() - if self.from_.startswith("@"): + if self.from_ is not None and self.from_.startswith("@"): if self.version is not None: raise ValueError("Version should not be set when the source is a stage") @@ -107,10 +107,10 @@ class Streamlit(NamedResource, TaggableResource, Resource): scope = SchemaScope() spec = _Streamlit - def init( + def __init__( self, name: str, - from_: str, + from_: str = None, version: Optional[str] = None, main_file: Optional[str] = None, title: Optional[str] = None, From 479b027ac8c437793847400f6a40d3946fa800de Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Sun, 26 Jul 2026 14:48:17 -0500 Subject: [PATCH 5/6] test: reset all ACCOUNT_USAGE caches between tests The four ACCOUNT_USAGE caches in data_provider are keyed by id(session), but the test classes covering them cleared only _ACCOUNT_USAGE_ACCESS_CACHE and _ACCOUNT_USAGE_FALLBACK_CACHE, leaving _ACCOUNT_USAGE_GRANTS_CACHE and _ACCOUNT_USAGE_USER_GRANTS_CACHE populated. CPython reuses the address of a freed object, so a fresh MagicMock session frequently lands on the previous test's address and inherits its cached grants (measured: 169 id() collisions per 200 sequential MagicMocks). When that collision happened, test_returns_none_on_access_control_error hit the grant list cached by test_returns_normalized_grants and returned early, never reaching the except branch, failing with: assert [{'created_on': ..., 'privilege': 'SELECT', ...}] is None This is allocation-dependent, which is why it surfaced only on build (3.12). Replace the six partial setup_method clears with one autouse fixture in the root conftest that calls the existing reset_account_usage_caches() helper, so all four caches are reset around every test in every file (pytest runs with --dist loadfile, so a worker can host several files). Verified on Python 3.12.12 and 3.13.9: with the address collision forced, the assertion fails before this change and passes after; ruff, mypy and the full 1537-test suite pass on both. Co-Authored-By: Claude Opus 5 (1M context) --- conftest.py | 15 +++++++++++ tests/test_data_provider.py | 50 ------------------------------------- 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/conftest.py b/conftest.py index 6b90935..7a53c9a 100644 --- a/conftest.py +++ b/conftest.py @@ -145,6 +145,21 @@ def dummy_cursor(request): yield request.getfixturevalue("cursor") +@pytest.fixture(autouse=True) +def reset_account_usage_caches_between_tests(): + """ + The ACCOUNT_USAGE caches in data_provider are keyed by id(session). CPython reuses the + addresses of freed objects, so a mock session in one test can land on the address of a + previous test's session and inherit its cached grants. Reset all four caches around every + test so cache state never leaks between them. + """ + from snowcap.data_provider import reset_account_usage_caches + + reset_account_usage_caches() + yield + reset_account_usage_caches() + + @pytest.fixture(autouse=True) def reset_cursor_context(dummy_cursor, test_db): """ diff --git a/tests/test_data_provider.py b/tests/test_data_provider.py index 2368656..d827e5f 100644 --- a/tests/test_data_provider.py +++ b/tests/test_data_provider.py @@ -853,15 +853,6 @@ def test_returns_region(self, mock_execute): class TestHasAccountUsageAccess: """Tests for _has_account_usage_access function.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import ( - _ACCOUNT_USAGE_ACCESS_CACHE, - _ACCOUNT_USAGE_FALLBACK_CACHE, - ) - _ACCOUNT_USAGE_ACCESS_CACHE.clear() - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - @patch("snowcap.data_provider.execute") def test_returns_true_when_access_granted(self, mock_execute): """When ACCOUNT_USAGE query succeeds, function returns True.""" @@ -936,15 +927,6 @@ def test_different_sessions_have_independent_cache(self, mock_execute): class TestFetchGrantsFromAccountUsage: """Tests for _fetch_grants_from_account_usage function.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import ( - _ACCOUNT_USAGE_ACCESS_CACHE, - _ACCOUNT_USAGE_FALLBACK_CACHE, - ) - _ACCOUNT_USAGE_ACCESS_CACHE.clear() - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - @patch("snowcap.data_provider.execute") def test_returns_normalized_grants(self, mock_execute): """Grants should be normalized to match SHOW GRANTS structure.""" @@ -1050,15 +1032,6 @@ def test_marks_fallback_on_error(self, mock_execute): class TestFetchRoleGrantsToUsersFromAccountUsage: """Tests for _fetch_role_grants_to_users_from_account_usage function.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import ( - _ACCOUNT_USAGE_ACCESS_CACHE, - _ACCOUNT_USAGE_FALLBACK_CACHE, - ) - _ACCOUNT_USAGE_ACCESS_CACHE.clear() - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - @patch("snowcap.data_provider.execute") def test_returns_normalized_user_grants(self, mock_execute): """User grants should be normalized to match SHOW GRANTS OF ROLE structure.""" @@ -1102,15 +1075,6 @@ def test_returns_none_on_error(self, mock_execute): class TestShouldUseAccountUsage: """Tests for _should_use_account_usage helper function.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import ( - _ACCOUNT_USAGE_ACCESS_CACHE, - _ACCOUNT_USAGE_FALLBACK_CACHE, - ) - _ACCOUNT_USAGE_ACCESS_CACHE.clear() - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - @patch("snowcap.data_provider._has_account_usage_access") def test_returns_false_when_config_disabled(self, mock_has_access): """Returns False when use_account_usage config is False.""" @@ -1166,11 +1130,6 @@ def test_returns_false_when_no_access(self, mock_has_access): class TestMarkAccountUsageFallback: """Tests for _mark_account_usage_fallback function.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import _ACCOUNT_USAGE_FALLBACK_CACHE - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - def test_marks_session_for_fallback(self): """Should mark session ID in fallback cache.""" from snowcap.data_provider import ( @@ -1187,15 +1146,6 @@ def test_marks_session_for_fallback(self): class TestFetchRolePrivilegesAccountUsage: """Tests for fetch_role_privileges with ACCOUNT_USAGE integration.""" - def setup_method(self): - """Clear caches before each test.""" - from snowcap.data_provider import ( - _ACCOUNT_USAGE_ACCESS_CACHE, - _ACCOUNT_USAGE_FALLBACK_CACHE, - ) - _ACCOUNT_USAGE_ACCESS_CACHE.clear() - _ACCOUNT_USAGE_FALLBACK_CACHE.clear() - @patch("snowcap.data_provider._should_use_account_usage") @patch("snowcap.data_provider._fetch_grants_from_account_usage") @patch("snowcap.data_provider._show_grants_to_role") From d68b5fddd0702a27d8387683c2dfd580e4fd1f88 Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Sun, 26 Jul 2026 14:56:23 -0500 Subject: [PATCH 6/6] build: pin ruff below 0.16 in dev extras The dev extra declared a bare "ruff", so CI installs whatever is current. ruff 0.16.0 expanded the default rule set (I001, RUF100, UP0xx, SIM1xx, TRY0xx and more), which reports 723 errors on snowcap/ as it stands on main and 725 on this branch. `ruff check snowcap/` is the first command in the CI check step, so every job now fails before the type check or tests run -- this is repo-wide, not specific to any branch. main only looks green because its last run predates the 0.16 release. Verified against a pristine origin/main tree: 723 errors under 0.16.0, zero under 0.15.22. Pin below 0.16 to restore the linter CI was passing with. The durable alternative is to declare the rule selection explicitly under [tool.ruff.lint] in pyproject.toml so future ruff releases cannot change what is enforced; left to the maintainers. Verified on Python 3.10.18, 3.11.13, 3.12.12 and 3.13.9: a fresh `pip install -e ".[dev]"` resolves ruff 0.15.22, and ruff, mypy and all 1537 tests pass on every version. Co-Authored-By: Claude Opus 5 (1M context) --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 43d3e34..d69b168 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,10 @@ "pytest-xdist", "pytest>=6.0", "python-dotenv", - "ruff", + # ruff 0.16 expanded the default rule set (I001, UP, SIM, TRY, ...), which + # reports 723 errors on snowcap/ as it stands on main. Pin below it until + # those are addressed or the rule selection is made explicit in pyproject. + "ruff<0.16", "tabulate", "twine!=5.1.0", "types-pytz",