Skip to content

feat(streamlit): resolve STREAMLIT grants at plan time (fetch_streamlit)#24

Open
usbrandon wants to merge 6 commits into
datacoves:mainfrom
usbrandon:feat/streamlit-grants-upstream
Open

feat(streamlit): resolve STREAMLIT grants at plan time (fetch_streamlit)#24
usbrandon wants to merge 6 commits into
datacoves:mainfrom
usbrandon:feat/streamlit-grants-upstream

Conversation

@usbrandon

Copy link
Copy Markdown
Contributor

What

snowcap plan errors whenever a grant references a Streamlit app:

module 'snowcap.data_provider' has no attribute 'fetch_streamlit'

StreamlitPriv, SchemaPriv.CREATE_STREAMLIT, and the Streamlit resource class already exist, so grant SQL renders — but because Streamlit has a concrete resource class, resolving on: streamlit <db>.<schema>.<app> drives fetch_resourcefetch_streamlit, which is missing.

Change

  • snowcap/data_provider.py: add fetch_streamlit()SHOW STREAMLITS + DESC STREAMLIT, mapping root_locationfrom_, plus main_file/title/query_warehouse/comment/owner (mirrors fetch_notebook).
  • tests/test_grant.py: test_grant_on_streamlit.
  • tests/test_privs.py: TestStreamlitPriv.
  • docs/resources/streamlit.md: "Granting access" section; docs/resources/grant.md: streamlit example.

Validation

Verified end-to-end against a live account: GRANT USAGE ON STREAMLIT now plans and applies (the previously-fatal fetch error is gone; the grant shows as a normal create). Full unit suite green.

🤖 Generated with Claude Code

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 <db>.<schema>.<app>`
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 <db>.<schema>.<app>
        to: <viewer_role>

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016L3b4GTh5nyFy5vxQ6e9TY
@noel

noel commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Thanks for this, @usbrandon — clean, well-scoped fix and the live validation is appreciated. Modeling it on fetch_notebook is the right call. A few specific changes I'd like before merge:

Requested changes

  1. Resolve the from_ / root_location drift. DESC STREAMLIT returns root_location as a fully-qualified, uppercased stage path (e.g. @DB.SCHEMA.MY_STAGE/app), while users declare from_="@my_stage". As written, stage-based apps will likely show perpetual drift. Please either drop from_ from the returned dict (as fetch_notebook does) or normalize root_location back to the declared form. If you can, add a plan run against an app whose config omits/short-forms from_ to confirm it reports no change.

  2. Handle the main_file default. fetch_notebook maps its default (notebook_app.ipynb) to None so an omitted field doesn't drift. Please apply the same treatment for Streamlit's default main_file (e.g. streamlit_app.py) → None.

  3. version hardcoded to None. Fine for stage apps, but a repo-based app (from_=https://…, version="main") would drift on version. Either populate it from the DESC/SHOW output or add a short code comment noting the known limitation.

Nits

  • Add trailing newlines to docs/resources/streamlit.md and docs/resources/grant.md.

Looks good

  • Return dict matches the _Streamlit spec field-for-field.
  • Using .get() / or None is more defensive than fetch_notebook — nice.
  • The owner's-rights explanation in the docs is correct and genuinely helpful.

No concern about the lack of a direct fetch_streamlit unit test — that's consistent with the rest of the fetch functions. Thanks again!

@usbrandon

Copy link
Copy Markdown
Contributor Author

All requested changes are in, plus live validation against a real account.

Requested changes

  1. from_ drift: dropped from_ from the fetch_streamlit dict and marked it fetchable: False on _Streamlit, mirroring _Notebook. YAML is now authoritative for from_.
  2. main_file default: streamlit_app.py now maps to None, same treatment as notebook_app.ipynb.
  3. version: kept as None with a code comment noting the limitation for repo-based apps — DESC STREAMLIT doesn't return a comparable value.

Live validation (short-form from_, main_file omitted, exactly the scenario you asked for):

  • Created a stage + app with CREATE STREAMLIT ... FROM '@APP_STAGE', then planned against a config declaring from_: "@app_stage" and no main_file.
  • Result: Plan: 0 to create, 0 to update — no drift. (The single transfer line is ownership only, expected since the test config omitted owner.)
  • Control on the pre-fix commit: old fetch returned from_: None vs the declared @app_stage — drift confirmed. Worth noting: on this account DESC STREAMLIT has no root_location column at all (newer versioning model), so the old code drifted on every stage app.

Also fixed a pre-existing blocker: Streamlit defined init instead of __init__, so declaring a streamlit in YAML failed with Invalid key "from_" — this is broken on main too. Renamed to __init__ (matching every other resource) and defaulted from_ to None on the spec/init since it's now non-fetchable. Unit suite: 1537 passed.

Nits: added the trailing newline to docs/resources/streamlit.md; docs/resources/grant.md already ends with one on this branch.

usbrandon and others added 2 commits July 26, 2026 14:48
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@usbrandon

Copy link
Copy Markdown
Contributor Author

Pushed two fixes for the failing checks. Neither failure originated in this PR — details below in case they're useful beyond this branch.

1. build (3.12) — pre-existing flake in tests/test_data_provider.py (479b027)

TestFetchGrantsFromAccountUsage::test_returns_none_on_access_control_error failed with:

assert [{'created_on': ..., 'privilege': 'SELECT', 'name': 'MY_DB.MY_SCHEMA.MY_TABLE', ...}] is None

The four ACCOUNT_USAGE caches in data_provider.py are keyed by id(session), but the six test classes covering them cleared only _ACCOUNT_USAGE_ACCESS_CACHE and _ACCOUNT_USAGE_FALLBACK_CACHE in setup_method_ACCOUNT_USAGE_GRANTS_CACHE and _ACCOUNT_USAGE_USER_GRANTS_CACHE survived between tests.

CPython reuses the address of a freed object, so a fresh MagicMock() session frequently lands on the previous test's address (measured: 169 id() collisions per 200 sequential MagicMock()s, 31 unique addresses). When that happens, the error test hits the grant list cached by test_returns_normalized_grants at the cache check in _fetch_grants_from_account_usage and returns early, never reaching the except branch.

Fix: one autouse fixture in the root conftest.py calling the existing reset_account_usage_caches() helper, replacing the six partial setup_method clears. Root conftest rather than module-local because pytest runs --dist loadfile, so a worker can host several test files.

Worth noting this isn't 3.12-specific — with the collision forced it reproduces on 3.10 and 3.11 as well; those jobs were just lucky with allocation ordering.

2. All three jobs — ruff 0.16 (d68b5fd)

The next run failed earlier, at ruff check snowcap/, on 3.10/3.11/3.12 alike — before mypy or pytest ran. The dev extra declared a bare "ruff", and 0.16.0 expanded the default rule set (I001, RUF100, UP0xx, SIM1xx, TRY0xx, …).

This is repo-wide rather than branch-specific. Against a pristine origin/main tree:

tree ruff 0.15.22 ruff 0.16.0
origin/main All checks passed 723 errors
this branch All checks passed 725 errors

main looks green only because its last Run Tests predates the 0.16 release. Any PR opened today hits this.

Fix: pin ruff<0.16 in the dev extra, restoring the linter CI was passing with. The durable alternative is declaring the rule selection explicitly under [tool.ruff.lint] in pyproject.toml so future ruff releases can't change what's enforced — happy to do that instead if you'd prefer; it's your lint policy, so I left it alone.

Verification

Real venvs per interpreter, running CI's exact commands:

ruff mypy pytest forced id() collision (before → after)
3.10.18 pass 34 files clean 1537 passed fails → passes
3.11.13 pass 34 files clean 1537 passed fails → passes
3.12.12 pass 34 files clean 1537 passed fails → passes
3.13.9 pass 34 files clean 1537 passed fails → passes

3.13 included since python_requires=">=3.10" has no upper bound — you may want "3.13" in the run-tests.yml matrix, but I didn't change the workflow here.

One thing I did not fix

Keying those caches by id(session) is also a production hazard, not just a test artifact: nothing holds a reference to the session, so a garbage-collected connection leaves its grant list behind, and a new connection allocated at the same address inherits it — stale grants at plan time. Out of scope here; happy to open a separate PR (weakref-keyed caches across the 8 session_id = id(session) sites) if you want it.

@noel

noel commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Thanks @usbrandon — reviewed and this looks solid. Confirmed the key pieces: the init__init__ rename is a genuine fix (on main, Streamlit defined def init, which is never called during construction, so the class was effectively unconstructible), fetch_streamlit is correctly wired via the fetch_{resource_label} naming convention, and the DESC STREAMLIT access mirrors fetch_notebook.

One small note before merge: making from_ optional (field(default=None)) removes a construction-time guardrail. It's correct for the fetch path (from_ is fetchable=False, so drift is ignored), but a create spec authored in YAML without from_ will now construct fine and only fail later at apply time with a malformed CREATE STREAMLIT (no ROOT_LOCATION). Worth a create-time check that from_ is present when the spec is being created rather than fetched. Not a blocker — happy to merge with or without it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants