Initial commit: Shelly Manager with Textual CLI, Streamlit UI, and comprehensive .gitignore

Shelly device management app with mDNS/subnet discovery, inventory,
configuration, and mass operations for Gen1/Gen2+ devices.

Includes .gitignore excluding runtime data (device DB, user config),
AI conversation history, build artifacts, and common Python/OS patterns.
This commit is contained in:
2026-03-23 21:51:59 +01:00
commit 71803418e5
152 changed files with 23405 additions and 0 deletions
@@ -0,0 +1,220 @@
---
name: developing-with-streamlit
description: "**[REQUIRED]** Use for ALL Streamlit tasks: creating, editing, debugging, beautifying, styling, theming, optimizing, or deploying Streamlit applications. Also required for building custom components (inline or packaged), using st.components.v2, or any HTML/JS/CSS component work. Triggers: streamlit, st., dashboard, app.py, beautify, style, CSS, color, background, theme, button, widget styling, custom component, st.components, packaged component, pyproject.toml, asset_dir, CCv2, HTML/JS component."
---
# Developing with Streamlit
This is a **routing skill** that directs you to specialized sub-skills for Streamlit development.
## When to Use
Invoke this skill when the user's request involves:
- Creating a new Streamlit app
- Editing or modifying an existing Streamlit app
- Debugging Streamlit issues (errors, session state bugs, performance problems)
- Beautifying or improving the visual design of a Streamlit app
- Optimizing Streamlit performance (caching, fragments, reruns)
- Deploying Streamlit apps (locally or to Snowflake)
- Styling widgets (button colors, backgrounds, CSS customization)
- Any question about Streamlit widgets, layouts, or components
**Trigger phrases:** "streamlit", "st.", "dashboard", "app.py", "beautify app", "make it look better", "style", "CSS", "color", "background", "theme", "button", "slow rerun", "session state", "performance", "faster", "cache", "deploy"
## Workflow
```
Step 1: Locate the Streamlit source code
Step 2: Identify task type and load appropriate sub-skill(s)
Step 3: Apply guidance from sub-skill to edit code
Step 4: Check if app is running and offer to run it
```
### Step 1: Locate the Streamlit Source Code (if needed)
**Goal:** Identify the app file(s) to edit. **Skip this step if already clear from context.**
**When to skip:**
- User mentioned a specific file path (e.g., "edit `src/app.py`")
- User has file(s) already in conversation context
- Working directory has an obvious single entry point (`app.py`, `streamlit_app.py`)
**When to search:**
- User says "my streamlit app" without specifying which file
- Multiple Python files exist and it's unclear which is the entry point
**If searching is needed:**
1. **Quick scan** for Streamlit files:
```bash
find . -name "*.py" -type f | xargs grep -l "import streamlit\|from streamlit" 2>/dev/null | head -10
```
2. **Apply entry point heuristics** (in priority order):
- `streamlit_app.py` at root → **this is the entry point** (canonical name)
- `app.py` at root → likely entry point
- File using `st.navigation` → entry point for multi-page apps
- Single `.py` file at root with streamlit import → entry point
- Files in `pages/` or `app_pages/` subdirectory → **NOT entry points** (these are sub-pages)
3. **If entry point is obvious** → use it, no confirmation needed
Example: Found `streamlit_app.py` and `pages/metrics.py` → use `streamlit_app.py`
4. **Only ask if genuinely ambiguous** (e.g., multiple root-level candidates, none named `streamlit_app.py`):
```
Found multiple potential entry points:
- dashboard.py
- main.py
Which is your main app?
```
**Output:** Path to the main Streamlit source file(s)
### Step 2: Identify Task Type and Route to Sub-Skill
**Goal:** Determine what the user needs and load the appropriate guidance.
Use this routing table to select sub-skill(s). **Always read the sub-skill file** before making changes:
| User Need | Sub-skill to Read |
|-----------|-------------------|
| **Performance issues, slow apps, caching** | `read skills/optimizing-streamlit-performance/SKILL.md` |
| **Building a dashboard with KPIs/metrics** | `read skills/building-streamlit-dashboards/SKILL.md` |
| **Improving visual design, icons, polish** | `read skills/improving-streamlit-design/SKILL.md` |
| **Choosing widgets (selectbox vs radio vs pills)** | `read skills/choosing-streamlit-selection-widgets/SKILL.md` |
| **Styling widgets (button colors, backgrounds, CSS)** | `read skills/creating-streamlit-themes/SKILL.md` |
| **Layouts (columns, tabs, sidebar, containers)** | `read skills/using-streamlit-layouts/SKILL.md` |
| **Displaying data (dataframes, charts)** | `read skills/displaying-streamlit-data/SKILL.md` |
| **Multi-page app architecture** | `read skills/building-streamlit-multipage-apps/SKILL.md` |
| **Session state and callbacks** | `read skills/using-streamlit-session-state/SKILL.md` |
| **Markdown, colored text, badges** | `read skills/using-streamlit-markdown/SKILL.md` |
| **Custom themes and colors** | `read skills/creating-streamlit-themes/SKILL.md` |
| **Comprehensive theme design and brand alignment** | `read skills/creating-streamlit-themes/SKILL.md` |
| **Chat interfaces and AI assistants** | `read skills/building-streamlit-chat-ui/SKILL.md` |
| **Connecting to Snowflake** | `read skills/connecting-streamlit-to-snowflake/SKILL.md` |
| **Building or packaging a custom component, triggering events back to Python from JS/HTML, custom HTML/JS with event handling (CCv2), OR any UI element that doesn't exist as a native Streamlit widget** (e.g., drag-and-drop, custom interactive visualization, canvas drawing) | `read skills/building-streamlit-custom-components-v2/SKILL.md` — **IMPORTANT: `st.components.v1` is deprecated. Never use v1 for new components; always use `st.components.v2.component()`.** |
| **Third-party components** | `read skills/using-streamlit-custom-components/SKILL.md` |
| **Code organization** | `read skills/organizing-streamlit-code/SKILL.md` |
| **Environment setup** | `read skills/setting-up-streamlit-environment/SKILL.md` |
| **CLI commands** | `read skills/using-streamlit-cli/SKILL.md` |
**Fallback — "this widget doesn't exist in Streamlit":**
If the user asks for a UI element or interaction that **has never been part of Streamlit's API** and cannot be built with any combination of native widgets (e.g., drag-and-drop, canvas drawing, custom interactive visualizations), **route to the CCv2 sub-skill** (`skills/building-streamlit-custom-components-v2/SKILL.md`). **Do not** route to CCv2 for features that exist in newer Streamlit versions (e.g., `st.connection`, `st.segmented_control`) — suggest upgrading instead.
**Common combinations:**
For **beautifying/improving an app**, read in order:
1. `skills/improving-streamlit-design/SKILL.md`
2. `skills/using-streamlit-layouts/SKILL.md`
3. `skills/choosing-streamlit-selection-widgets/SKILL.md`
For **building a dashboard**, read:
1. `skills/building-streamlit-dashboards/SKILL.md`
2. `skills/displaying-streamlit-data/SKILL.md`
**IMPORTANT - Use templates:**
When creating a **new dashboard app**, prefer starting from a template in `templates/apps/`:
- If a template closely matches the request, copy it and adapt:
- `dashboard-metrics` / `dashboard-metrics-snowflake` — KPI cards with time-series charts
- `dashboard-companies` — company/entity comparison
- `dashboard-compute` / `dashboard-compute-snowflake` — resource/credit monitoring
- `dashboard-feature-usage` — feature adoption tracking
- `dashboard-seattle-weather` — public dataset exploration (local only)
- `dashboard-stock-peers` / `dashboard-stock-peers-snowflake` — financial peer analysis
- If no template is a close match, start from scratch but borrow relevant patterns from the templates (e.g., caching with `@st.cache_data`, `filter_by_time_range()`, `st.set_page_config()`, chart utilities, layout structure)
- See `templates/apps/README.md` for template descriptions
When **editing an existing app**, use templates as reference for best practices:
- Check `templates/apps/` for caching patterns, layout structure, and Snowflake integration
- Apply consistent patterns from templates to improve the existing code
When applying a **custom theme**, use a template from `templates/themes/`:
- Copy a theme directory (snowflake, dracula, nord, stripe, solarized-light, spotify, github, minimal)
- Themes use Google Fonts for easy setup
- See `templates/themes/README.md` for theme previews
For **performance optimization**, read:
1. `skills/optimizing-streamlit-performance/SKILL.md`
### Step 3: Apply Guidance to Edit Code
**Goal:** Make changes to the Streamlit app following sub-skill best practices.
**Actions:**
1. Apply the patterns and recommendations from the loaded sub-skill(s)
2. Make edits to the source file(s) identified in Step 1
3. Preserve existing functionality while adding improvements
### Step 4: Check Running Apps and Offer to Run
**Goal:** Help the user see their changes by checking if their app is running.
**Actions:**
1. **Check** for running Streamlit apps on ports 850*:
```bash
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i python | awk '{print $2, $9}' | grep ':85' || echo "No Streamlit apps detected on ports 850*"
```
2. **Present** findings to user:
**If app is running:**
```
Found Streamlit app running:
- PID: [pid] at http://localhost:[port]
Your changes should be visible after a page refresh (Streamlit hot-reloads on file save).
```
**If no app is running:**
```
No Streamlit app detected on ports 850*.
Would you like me to run the app? I can start it with:
streamlit run [app_file.py]
```
3. **If user wants to run the app**, start it:
```bash
streamlit run [path/to/app.py] --server.port 8501
```
## Stopping Points
- **Step 2**: If multiple sub-skills seem relevant, ask user which aspect to focus on first
- **Step 4**: Ask before starting the Streamlit app
## Skill map
| Skill | Covers |
|-------|--------|
| [building-streamlit-chat-ui](skills/building-streamlit-chat-ui/SKILL.md) | Chat interfaces, streaming responses, message history |
| [building-streamlit-dashboards](skills/building-streamlit-dashboards/SKILL.md) | KPI cards, metrics, dashboard layouts |
| [building-streamlit-multipage-apps](skills/building-streamlit-multipage-apps/SKILL.md) | Page structure, navigation, shared state |
| [building-streamlit-custom-components-v2](skills/building-streamlit-custom-components-v2/SKILL.md) | Streamlit Custom Components v2 (inline and template-based packaged), bidirectional state/trigger callbacks, bundling, theme CSS variables |
| [choosing-streamlit-selection-widgets](skills/choosing-streamlit-selection-widgets/SKILL.md) | Selectbox vs radio vs segmented control vs pills vs multiselect |
| [connecting-streamlit-to-snowflake](skills/connecting-streamlit-to-snowflake/SKILL.md) | st.connection, query caching, credentials |
| [creating-streamlit-themes](skills/creating-streamlit-themes/SKILL.md) | Theme configuration, colors, fonts, light/dark modes, professional brand alignment, CSS avoidance |
| [displaying-streamlit-data](skills/displaying-streamlit-data/SKILL.md) | Dataframes, column config, charts |
| [improving-streamlit-design](skills/improving-streamlit-design/SKILL.md) | Icons, badges, colored text, visual polish |
| [optimizing-streamlit-performance](skills/optimizing-streamlit-performance/SKILL.md) | Caching, fragments, forms, static vs dynamic widgets |
| [organizing-streamlit-code](skills/organizing-streamlit-code/SKILL.md) | When to split into modules, separating UI from logic |
| [setting-up-streamlit-environment](skills/setting-up-streamlit-environment/SKILL.md) | Python environment, dependency management |
| [using-streamlit-custom-components](skills/using-streamlit-custom-components/SKILL.md) | Third-party components from the community |
| [using-streamlit-cli](skills/using-streamlit-cli/SKILL.md) | CLI commands, running apps |
| [using-streamlit-layouts](skills/using-streamlit-layouts/SKILL.md) | Sidebar, columns, containers, tabs, expanders, dialogs, alignment, spacing |
| [using-streamlit-markdown](skills/using-streamlit-markdown/SKILL.md) | Colored text, badges, icons, LaTeX, and all markdown features |
| [using-streamlit-session-state](skills/using-streamlit-session-state/SKILL.md) | Session state, widget keys, callbacks, state persistence |
## Resources
- [Streamlit API Reference](https://docs.streamlit.io/develop/api-reference)
- [Streamlit Gallery](https://streamlit.io/gallery)
@@ -0,0 +1,195 @@
---
name: building-streamlit-chat-ui
description: Building chat interfaces in Streamlit. Use when creating conversational UIs, chatbots, or AI assistants. Covers st.chat_message, st.chat_input, message history, and streaming responses.
license: Apache-2.0
---
# Streamlit chat interfaces
Build conversational UIs with Streamlit's chat elements.
## Basic chat structure
```python
import streamlit as st
if "messages" not in st.session_state:
st.session_state.messages = []
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.write(msg["content"])
# Handle new input
if prompt := st.chat_input("Ask a question"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
response = get_response(prompt) # Your LLM call
st.write(response)
st.session_state.messages.append({"role": "assistant", "content": response})
```
## Streaming responses
Use `st.write_stream` for token-by-token display. Pass any generator that yields strings, including the OpenAI generator directly:
```python
def get_streaming_response(prompt):
# Replace with your LLM client (OpenAI, Anthropic, Cortex, etc.)
for chunk in your_llm_client.stream(prompt):
yield chunk
with st.chat_message("assistant"):
response = st.write_stream(get_streaming_response(prompt))
st.session_state.messages.append({"role": "assistant", "content": response})
```
With OpenAI, you can pass the stream directly:
```python
from openai import OpenAI
client = OpenAI()
with st.chat_message("assistant"):
stream = client.chat.completions.create(
model="gpt-4o",
messages=st.session_state.messages,
stream=True,
)
response = st.write_stream(stream)
```
## Chat message avatars
Streamlit provides default avatars for "user" and "assistant" roles—only customize if you have a specific need. You can use icons or images:
```python
# With icons
with st.chat_message("assistant", avatar=":material/robot:"):
st.write(assistant_message)
# With images
with st.chat_message("user", avatar="https://example.com/avatar.png"):
st.write(user_message)
```
## Suggestion chips
Offer clickable suggestions before the first message. The pills disappear once the user sends a message, creating a clean onboarding experience:
```python
SUGGESTIONS = {
":blue[:material/help:] What is Streamlit?": "Explain what Streamlit is",
":green[:material/code:] Show me an example": "Show a simple Streamlit example",
}
# Only show before first message - they disappear after
if not st.session_state.messages:
selected = st.pills("Try asking:", list(SUGGESTIONS.keys()), label_visibility="collapsed")
if selected:
# Use the selection as the first prompt
prompt = SUGGESTIONS[selected]
st.session_state.messages.append({"role": "user", "content": prompt})
st.rerun()
```
The `if not st.session_state.messages` check ensures the suggestions only appear on an empty chat. Once a message is added, the pills vanish and the conversation takes over.
## File uploads
Enable file attachments with `accept_file`. When enabled, `st.chat_input` returns a dict-like object with `text` and `files` attributes:
```python
prompt = st.chat_input(
"Ask about an image",
accept_file=True,
file_type=["jpg", "jpeg", "png"],
)
if prompt:
with st.chat_message("user"):
if prompt.text:
st.write(prompt.text)
if prompt.files:
st.image(prompt.files[0])
# Send to vision model
with st.chat_message("assistant"):
response = analyze_image(prompt.files[0], prompt.text)
st.write(response)
```
Use `accept_file="multiple"` to allow multiple files.
## Audio input
Enable voice recording with `accept_audio`. The recorded audio is available as a WAV file:
```python
prompt = st.chat_input("Say something", accept_audio=True)
if prompt:
if prompt.audio:
st.audio(prompt.audio)
if prompt.text:
st.write(prompt.text)
```
### Dictation with speech-to-text
Convert audio to text and inject it back into the chat input:
```python
prompt = st.chat_input("Say something", accept_audio=True, key="chat")
if prompt and prompt.audio:
# Transcribe with Whisper or another STT model
transcript = openai.audio.transcriptions.create(
model="whisper-1",
file=prompt.audio,
)
# Set the transcribed text as the next input
st.session_state.chat = transcript.text
st.rerun()
```
## User feedback
Add thumbs up/down feedback to assistant messages. Also supports `"stars"` and `"faces"` ratings:
```python
with st.chat_message("assistant"):
st.markdown(response)
feedback = st.feedback("thumbs")
if feedback is not None:
st.toast(f"Feedback received: {'👍' if feedback == 1 else '👎'}")
```
## Clear chat
Add a button to reset the conversation:
```python
def clear_chat():
st.session_state.messages = []
st.button("Clear chat", on_click=clear_chat)
```
## Related skills
- `connecting-streamlit-to-snowflake`: Database queries and Cortex chat example
- `optimizing-streamlit-performance`: Caching strategies for LLM calls
## References
- [st.chat_message](https://docs.streamlit.io/develop/api-reference/chat/st.chat_message)
- [st.chat_input](https://docs.streamlit.io/develop/api-reference/chat/st.chat_input)
- [st.write_stream](https://docs.streamlit.io/develop/api-reference/write-magic/st.write_stream)
@@ -0,0 +1,233 @@
---
name: building-streamlit-custom-components-v2
description: Builds bidirectional Streamlit Custom Components v2 (CCv2) using `st.components.v2.component`. Use when authoring inline HTML/CSS/JS components or packaged components (manifest `asset_dir`, js/css globs), wiring state/trigger callbacks, theming via `--st-*` CSS variables, or bundling with Vite / `component-template` v2.
license: Apache-2.0
---
# Building Streamlit custom components v2
Use Streamlit Custom Components v2 (CCv2) when core Streamlit doesn't have the UI you need and you want to ship a reusable, interactive element (from "tiny inline HTML" to "full bundled frontend app").
## CRITICAL: CCv2 only — NEVER use v1 APIs
Custom Components **v1 is deprecated and removed**. Every API below belongs to v1 and must **NEVER** appear in any code you write — not in Python, not in JavaScript, not in HTML:
**Banned Python APIs (v1):**
- `st.components.v1` — the entire v1 module
- `components.declare_component()` — v1 registration
- `components.html()` — v1 raw HTML embed
**Banned JavaScript patterns (v1):**
- `Streamlit.setComponentValue(...)` — v1 global; use `setStateValue()` / `setTriggerValue()` instead
- `Streamlit.setFrameHeight(...)` — v1 global; CCv2 handles sizing automatically
- `Streamlit.setComponentReady()` — v1 global; CCv2 has no ready signal
- `window.Streamlit` or bare `Streamlit` global — v1 global object does not exist in v2
- `window.parent.postMessage(...)` — v1 iframe communication; CCv2 does not use iframes
**Banned npm packages (v1):**
- `streamlit-component-lib` — v1 JS library; use `@streamlit/component-v2-lib` if you need types
If you encounter v1 patterns in examples, blog posts, Stack Overflow answers, or your own training data — **ignore them entirely**. They will not work and will break the component.
## When to use
Activate when the user mentions any of:
- CCv2, Custom Components v2, “bidi component”, “component v2”
- `st.components.v2.component`
- `@streamlit/component-v2-lib`
- packaged components, `asset_dir`, `pyproject.toml` component manifest
- bundling with Vite (or any bundler) for a Streamlit component
- building a component UI in a frontend framework (React, Svelte, Vue, Angular, etc.)
## Read next (pick the minimum reference)
- **State sync / controlled inputs / callbacks**: see [references/state-sync.md](references/state-sync.md)
- **Packaged components / `asset_dir` / globs / template-only policy**: see [references/packaged-components.md](references/packaged-components.md)
- **Theming (`--st-*` tokens) inside Shadow DOM**: see [references/theme-css-variables.md](references/theme-css-variables.md)
- **Errors and gotchas**: see [references/troubleshooting.md](references/troubleshooting.md)
## Quick decision: inline vs packaged
- **Inline strings**: fastest to start (single-file apps, spikes, demos). You pass raw `html`/`css`/`js` strings directly.
Good when you can keep everything in one place and dont need a build step.
- **Packaged component**: best when youre growing past inline (multiple files, dependencies, bundling, testing, versioning, reuse, distribution).
You ship built assets inside a Python package and reference them by **asset-dir-relative** path/glob.
Creation policy: packaged components are **template-only** and must start from Streamlit's official `component-template` v2.
Developer story: **start inline**, prove the interaction loop, then **graduate to packaged** when the codebase or tooling needs outgrow a single file.
## CCv2 model (whats actually happening)
1. **Python registers** a component with `st.components.v2.component(...)` and gets back a **mount callable**.
2. The mount callable **mounts** the component in the app with `data=...`, layout (`width`, `height`), and optional `on_<key>_change` callbacks.
3. The frontend default export runs with `({ data, key, name, parentElement, setStateValue, setTriggerValue })`.
4. The component returns a **result object** whose attributes correspond to **state keys** and **trigger keys**.
## Best practice: wrap the mount callable in your own Python API
Prefer exposing **your own** Python function that wraps the callable returned by `st.components.v2.component(...)`.
This gives you a clean, stable API surface for end users (typed parameters, validation, friendly defaults) and keeps `data=...`, `default=...`, and callback wiring as an internal detail.
Important:
- Declare the component **once** (usually at module import time). Avoid defining and registering the component inside a function you call multiple times; you can accidentally re-register the component name and get confusing behavior.
References:
- [`st.components.v2.component`](https://docs.streamlit.io/develop/api-reference/custom-components/st.components.v2.component)
- [`ComponentRenderer` (mount callable type)](https://docs.streamlit.io/develop/api-reference/custom-components/st.components.v2.types.componentrenderer)
Example pattern:
```python
import streamlit as st
from collections.abc import Callable
_MY_COMPONENT = st.components.v2.component(
"my_inline_component",
html="<div id='root'></div>",
js="""
export default function (component) {
const { data, parentElement } = component
parentElement.querySelector("#root").textContent = data?.label ?? ""
}
""",
)
def my_component(
label: str,
*,
key: str | None = None,
on_value_change: Callable[[], None] | None = None,
on_submitted_change: Callable[[], None] | None = None,
):
# Callbacks are optional, but if you want result attributes to always exist,
# provide (even empty) callbacks.
if on_value_change is None:
on_value_change = lambda: None
if on_submitted_change is None:
on_submitted_change = lambda: None
return _MY_COMPONENT(
data={"label": label},
key=key,
on_value_change=on_value_change,
on_submitted_change=on_submitted_change,
)
```
## Inline quickstart (state + trigger)
**Reminder: use ONLY v2 APIs.** Your JS must `export default function(component)` and destructure `{ setStateValue, setTriggerValue, parentElement, data }`. NEVER use `Streamlit.setComponentValue()`, `window.Streamlit`, or any v1 pattern.
This is the minimum "bidi loop":
- **JS → Python**: emit updates via `setStateValue(...)` (persistent) and `setTriggerValue(...)` (event)
- **Python → JS**: re-hydrate UI via `data=...` on every run
```python
import streamlit as st
HTML = """<input id="txt" /><button id="btn" type="button">Submit</button>"""
JS = """\
export default function (component) {
const { data, parentElement, setStateValue, setTriggerValue } = component
const input = parentElement.querySelector("#txt")
const btn = parentElement.querySelector("#btn")
if (!input || !btn) return
const nextValue = (data && data.value) ?? ""
if (input.value !== nextValue) input.value = nextValue
input.oninput = (e) => {
setStateValue("value", e.target.value)
}
btn.onclick = () => {
setTriggerValue("submitted", input.value)
}
}
"""
my_text_input = st.components.v2.component(
"my_inline_text_input",
html=HTML,
js=JS,
)
KEY = "txt-1"
component_state = st.session_state.get(KEY, {})
value = component_state.get("value", "")
result = my_text_input(
key=KEY,
data={"value": value},
on_value_change=lambda: None, # optional; include to always get `result.value`
on_submitted_change=lambda: None, # optional; include to always get `result.submitted`
)
st.write("value (state):", result.value)
st.write("submitted (trigger):", result.submitted)
```
Notes:
- **Inline JS/CSS should be multi-line**. CCv2 treats path-like strings as file references; a multi-line string is unambiguously inline content.
- Prefer querying under `parentElement` (not `document`) to avoid cross-instance leakage.
## State and triggers (how to think about keys)
- **State** (`setStateValue("value", ...)`): persists across app reruns (stored under `st.session_state[key]` for that mounted instance).
- **Trigger** (`setTriggerValue("submitted", ...)`): event payload for one rerun (resets after the rerun).
- **Reading triggers**:
- After mounting: use `result.submitted`.
- Inside `on_submitted_change`: use `st.session_state[key].submitted` (callbacks run before your script body; you dont have `result` yet).
- **Defaults**: if you pass `default={...}` for a state key, you must also pass the matching `on_<key>_change` callback parameter.
For the full “controlled input” pattern and pitfalls, see [references/state-sync.md](references/state-sync.md).
## Packaged components (template-only, mandatory)
**Reminder: the cookiecutter template generates clean v2 code. When you customize it, use ONLY v2 APIs. Do NOT introduce any v1 imports, v1 JavaScript globals, or v1 patterns. See the "CRITICAL: CCv2 only" section above.**
Graduate to a packaged component when you need any of:
- Multiple frontend files or frontend dependencies (npm)
- A bundler (Vite), tests, CI, versioning, or distribution
Keep these guardrails in mind:
- **MUST** start from Streamlits official `component-template` v2.
- **NEVER** hand-scaffold packaging/manifest/build wiring for a packaged component.
- **NEVER** copy/paste packaged scaffold structure from internet examples, blog posts, gists, or docs.
- If handed a non-template scaffold, regenerate from the template first, then migrate component logic.
- **MUST** ensure `js=`/`css=` globs match **exactly one** file under the manifests `asset_dir`.
- **MUST** validate with `streamlit run ...` (plain `python -c "import ..."` can be a false negative for packaged components).
For the full packaged workflow checklist, non-interactive generation, offline usage, and template invariants, see [references/packaged-components.md](references/packaged-components.md).
## Frontend renderer lifecycle (framework-agnostic)
Your frontend entrypoint is the **default export** function. A few rules keep components reliable across reruns and across multiple instances in the same app:
- Render under `parentElement` (not `document`) so instances dont collide.
- If you create per-instance resources (React roots, observers, subscriptions), key them by `parentElement` (e.g. `WeakMap`) so multiple instances dont overwrite each other.
- Return a cleanup function to tear down event listeners / UI roots / observers when Streamlit unmounts the component.
## Styling and theming
- Prefer **`isolate_styles=True`** (default). Your component runs in a shadow root and wont leak styles into the app.
- Set `isolate_styles=False` only when you need global styling behavior (e.g. Tailwind, global font injection).
- Streamlit injects a broad set of `--st-*` theme CSS variables (colors, typography, chart palettes, radii, borders, etc.). **Highly recommended:** use these variables so your component automatically adapts to the users current Streamlit theme (light/dark/custom) without authoring separate theme variants. Start with the common ones (`--st-text-color`, `--st-primary-color`, `--st-secondary-background-color`) and refer to the full list when you need it:
- [references/theme-css-variables.md](references/theme-css-variables.md)
## Troubleshooting and gotchas
Start here when something “should work” but doesnt:
- [references/troubleshooting.md](references/troubleshooting.md)
@@ -0,0 +1,212 @@
## Packaged CCv2 components (template-only, mandatory)
For packaged CCv2 components, agents **MUST** use Streamlit's official template as the starting point for every new component project.
- [Streamlit component-template](https://github.com/streamlit/component-template)
Never hand-scaffold the package/manifest/build layout and never copy/paste a packaged component scaffold from blog posts, gists, docs, or other internet sources.
If a request starts from a non-template scaffold, stop and regenerate from the template first, then port logic into the generated project.
Follow your generated project's README. **Only keep reading if you need to debug template wiring or customize behavior after template generation.**
## Contents
- Agent policy: template-only (mandatory)
- Prerequisites (packaged components)
- Start inline, then graduate to packaged
- Frontend framework note (React is optional)
- TypeScript support (recommended)
- Generate a new CCv2 component project
- Non-interactive generation (cookiecutter keys)
- Offline/airgapped
- Dev loop (template default)
- Verify the build output (prevents most load failures)
- Template invariants (dont break these)
- Rename checklist (avoid placeholder-name drift)
- If you intentionally deviate from the template
- Verification recommendation
### Agent policy: template-only (mandatory)
If the request is for a packaged CCv2 component:
- Start from the official template first (no exceptions).
- Never manually scaffold a custom package/manifest/build layout before template generation.
- Never copy a packaged component scaffold from the internet, even as a "starting point."
- If given existing non-template scaffolding, regenerate from the template and migrate code into it.
- Customize only after generation so you retain known-good packaging defaults.
### Prerequisites (packaged components)
- **Python build tooling**: `uv` (recommended) + `cookiecutter`.
- **Frontend build tooling**: Node.js + npm.
### Start inline, then graduate to packaged
Inline components are great for getting started quickly. Move to a packaged component when you hit any of these:
- You need **multiple frontend files** (components/modules) instead of one big string.
- You want to pull in **frontend libraries** (npm deps) and run a bundler.
- You need **tests**, CI, versioning, or distribution (PyPI/private index).
### Frontend framework note (React is optional)
The official Streamlit `component-template` v2 supports both **React + TypeScript (Vite)** and **Pure TypeScript (Vite)** (no React). CCv2 also works with **any frontend framework that compiles to JavaScript** (Svelte, Vue, Angular, vanilla TS/JS, etc.).
The only requirement is that you produce JS/CSS assets into your components `asset_dir`, then register them from Python via `html=...`, `js="..."`, and `css="..."` using **asset-dir-relative** paths/globs.
### TypeScript support (recommended)
For end-to-end type safety while authoring the frontend, install `@streamlit/component-v2-lib`:
- [npm package](https://www.npmjs.com/package/@streamlit/component-v2-lib)
- [docs](https://docs.streamlit.io/develop/api-reference/custom-components/component-v2-lib)
It provides TypeScript types like `FrontendRenderer` / `FrontendRendererArgs` so your `export default` renderer gets a **typed** `data` payload and typed state/trigger keys via generics.
### Generate a new CCv2 component project
This command is the required starting point for every packaged CCv2 component:
```bash
uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2
```
If you run this non-interactively, pass explicit cookiecutter values (do not rely on defaults):
Template keys:
- `author_name`
- `author_email`
- `project_name`
- `package_name`
- `import_name`
- `description`
- `open_source_license`
- `framework`
Recommended non-interactive invocation:
This sample uses a **hypothetical breadcrumb component** name so the values are concrete and meaningful:
```bash
uvx --from cookiecutter cookiecutter gh:streamlit/component-template \
--directory cookiecutter/v2 \
--no-input \
author_name="Your Name" \
author_email="you@example.com" \
project_name="Streamlit Breadcrumbs" \
package_name="streamlit-breadcrumbs" \
import_name="streamlit_breadcrumbs" \
description="Packaged Streamlit CCv2 breadcrumb component" \
open_source_license="Apache-2.0" \
framework="React + Typescript"
```
Notes:
- Choice values must match template options exactly (`framework` is `"React + Typescript"` or `"Pure Typescript"`).
- Passing all keys avoids template placeholder names and post-generation rename churn.
Offline/airgapped:
```bash
uvx --from cookiecutter cookiecutter /path/to/component-template --directory cookiecutter/v2
```
### Dev loop (template default)
From the generated project:
1. Activate the target project environment before Python/uv commands:
```bash
source /path/to/project/.venv/bin/activate
```
2. Build the frontend assets (from `<import_name>/frontend`):
```bash
npm i
npm run build
```
3. Editable install (project root containing `pyproject.toml`):
```bash
uv pip install -e . --force-reinstall
```
4. Run the example app with Streamlit:
```bash
streamlit run example.py
```
Why this order:
- Building first ensures `asset_dir` contains the expected files before install/use.
- Reinstalling editable after key renames keeps metadata and import paths in sync.
### Packaged component workflow (copy/paste checklist)
Use this when debugging or customizing after generation; it's designed to prevent the common "built assets exist but Streamlit can't load them" failure modes.
```
Packaged CCv2 checklist
- [ ] Generate project from `component-template` v2
- [ ] Confirm this is template-generated (not hand-scaffolded, not copied from internet snippets)
- [ ] Activate the target project environment before Python/uv commands
- [ ] Rename template defaults (`streamlit-component-x`, `streamlit_component_x`, etc.) if needed
- [ ] Build frontend assets into the manifests `asset_dir` (template: `frontend/build/`)
- [ ] Editable install the Python package (`uv pip install -e . --force-reinstall`)
- [ ] Verify `js=`/`css=` globs match exactly one file each under `asset_dir`
- [ ] Run via `streamlit run ...` and confirm the component renders/events work
- [ ] If something breaks: read `references/troubleshooting.md`, fix, rebuild, re-verify glob uniqueness
```
### Verify the build output (prevents most load failures)
- Ensure the manifests `asset_dir` exists and contains the built assets.
- Ensure each glob you register from Python matches **exactly one** file under `asset_dir`:
- Typical: `js="index-*.js"` and `css="index-*.css"`
- If multiple matches: clean the build output (template: `npm run clean`) and rebuild.
### Template invariants (dont break these)
You typically shouldnt need to touch these, but they explain most “why wont this load?” failures:
- **Component key**: the Python registration key must match the manifest: `"<project.name>.<component.name>"`.
- **Manifest must ship inside the Python package**: the template places a minimal CCv2 manifest at `<import_name>/pyproject.toml` with `asset_dir = "frontend/build"`.
- **Asset paths are asset-dir-relative strings**: `js="index-*.js"` (template default output) or `js="assets/index-*.js"` (if you configured an `assets/` subdir).
- **Globs must match exactly one file**: if `index-*.js` matches multiple hashed builds, clean the build output (`npm run clean`) and rebuild.
### Rename checklist (avoid placeholder-name drift)
Template defaults like `streamlit-component-x` / `streamlit_component_x` should be replaced everywhere early.
Rename all of these together:
- Root folder name (optional but recommended for clarity).
- Distribution name (`[project].name`) in root `pyproject.toml`.
- Import package directory (`streamlit_<real_name>`).
- In-package manifest file and contents (`<import_name>/pyproject.toml`).
- Wrapper registration key:
- `st.components.v2.component("<project.name>.<component.name>", ...)`
- `MANIFEST.in` and `[tool.setuptools.*]` references.
- README/example imports and frontend package name.
### Allowed customizations (after template generation only)
Keep the blast radius small:
- If you change output layout, update only the `js=`/`css=` asset-dir-relative globs in the Python wrapper.
- For Vite, keep `base: "./"` so relative URLs work when served from Streamlits component URLs.
### Verification recommendation
Validate packaged components with `streamlit run ...`, not plain `python -c "import ..."` checks.
- Streamlit discovers component manifests as part of runtime setup.
- Plain import checks can report false-negative `asset_dir` registration errors for otherwise-correct packaged components.
@@ -0,0 +1,149 @@
## State sync patterns (JS ↔ Python)
This reference shows the canonical CCv2 “controlled component” loop and the most common pitfalls when syncing state between JavaScript and Python.
## Contents
- Mental model
- Canonical pattern: controlled text input
- JavaScript (hydrate from `data`, emit via `setStateValue`)
- Python wrapper (feed state back down via `data`)
- Defaults: when to use `default=...` (and why it fails)
- Python → JS hydration: initial-only vs true sync
- Session State timing: dont mutate after mount
- Troubleshooting checklist
### Mental model
- **Frontend state emission (JS → Python)**: you explicitly call `setStateValue(key, value)` or `setTriggerValue(key, value)`.
- **Frontend state hydration (Python → JS)**: your JS reads `component.data` and updates the DOM accordingly.
- There is no built-in “two-way binding”: you must implement both sides.
### Canonical pattern: controlled text input
This is modeled after Streamlits own CCv2 e2e example.
#### JavaScript (hydrate from `data`, emit via `setStateValue`)
Key guideline: only assign to the input when its different, or youll fight the users cursor.
```js
export default function (component) {
const { parentElement, data, setStateValue } = component
const label = parentElement.querySelector("label")
const input = parentElement.querySelector("input")
if (!label || !input) return
label.innerText = data.label
const nextValue = data.value ?? ""
if (input.value !== nextValue) {
input.value = nextValue
}
input.onkeydown = e => {
if (e.key === "Enter") {
setStateValue("value", e.target.value)
}
}
}
```
#### Python wrapper (feed state back down via `data`)
```python
import streamlit as st
_COMPONENT = st.components.v2.component(
"interactive_text_input",
html="""
<label for="txt">Enter text:</label>
<input id="txt" type="text" />
""",
js=JS, # inline JS string from above
)
def interactive_text_input(*, label: str, initial_value: str, key: str):
# 1) Read current component state from Session State (if it exists)
component_state = st.session_state.get(key, {})
# 2) Compute the value you want the UI to display
value = component_state.get("value", initial_value)
# 3) Send it down to the frontend via `data`
return _COMPONENT(
key=key,
data={"label": label, "value": value},
)
KEY = "my_text_input"
if st.button("Make it say Hello World"):
st.session_state.setdefault(KEY, {})["value"] = "Hello World"
interactive_text_input(label="Enter something", initial_value="Initial Text", key=KEY)
```
### Defaults: when to use `default=...` (and why it fails)
`default={...}` is optional. Use it when you want Streamlit to initialize missing state keys for a mounted instance.
Rules:
- Defaults apply only to **state** keys (not triggers).
- Every key in `default` must have a corresponding `on_<key>_change` callback parameter when mounting, or Streamlit raises.
Pattern:
```python
result = _COMPONENT(
key=key,
data={"value": value},
default={"value": value},
on_value_change=lambda: None, # required if using default["value"]
)
```
### Python → JS hydration: initial-only vs true sync
Youll see two patterns in the wild:
- **Initial-only hydration**: JS reads `data.initialX` on first mount only. This is useful for *initialization* but it will **not** reflect later Python changes.
- **True sync (controlled)**: JS reconciles its UI from `data.value` on every render, and only writes when changed.
Initial-only example (pitfall for sync):
```js
// If you guard hydration with hasMounted, Python changes won't propagate.
if (typeof data?.initialText !== "undefined" && !hasMountedForKey) {
input.value = String(data.initialText)
}
hasMounted[key] = true
```
True sync approach (recommended when Python can update the UI):
```js
const nextValue = data.value ?? ""
if (input.value !== nextValue) input.value = nextValue
```
### Session State timing: dont mutate after mount
Streamlit may raise if you modify `st.session_state.<key>.<field>` **after** the component with that key has been instantiated in the same run.
Safe patterns:
- Update `st.session_state[key][...]` **before** mounting the component (e.g., in a button handler placed above the mount call).
- Or update state in a different run (trigger a rerun after setting state).
### Troubleshooting checklist
- **Cursor jumps / typing feels broken**: ensure your JS only assigns `input.value` when it differs from the `data` value.
- **Python updates dont reflect in UI**: confirm you pass the updated values via `data` every run; avoid initial-only hydration guards if you want true sync.
- **`default` raises**: ensure every default key has a corresponding `on_<key>_change` callback parameter.
- **Session state mutation error**: move `st.session_state[key][...] = ...` earlier in the script (before mount), or restructure into a two-run flow (set state then rerun).
@@ -0,0 +1,243 @@
## Streamlit theme CSS variables for CCv2 components (`--st-*`)
Streamlit injects a set of `--st-*` CSS custom properties into CCv2 components so your component can match the apps active theme from within your component CSS (including when `isolate_styles=True` and youre rendering inside a shadow root).
**Recommendation:** Prefer `var(--st-…)` over hard-coded colors and sizes. These variables automatically adapt to a user's current Streamlit theme (light/dark/custom), so most components **do not** need separate “dark mode vs light mode” styling.
## Contents
- Quick start
- Gotchas: serialization + fallbacks
- Cheat sheet: the 90% variables (intent → `--st-*`)
- Foundation tokens (surfaces, text, borders, shape)
- Typography tokens
- Data display tokens (dataframes/tables)
- Data visualization tokens (chart palettes)
- Semantic/status palette (red/green/etc.)
- Appendix: full variable index (alphabetical)
### Quick start
Use them like any CSS variable:
```css
.card {
background: var(--st-secondary-background-color);
color: var(--st-text-color);
border: 1px solid var(--st-border-color);
border-radius: var(--st-base-radius);
}
.primaryButton {
background: var(--st-primary-color);
color: var(--st-background-color);
border-radius: var(--st-button-radius);
}
```
### Gotchas: serialization rules + safe fallbacks
These variables originate from Streamlits theme object and are serialized into strings:
- **Strings**: passed through (e.g. `--st-primary-color: #ff4b4b`).
- **Numbers**: stringified (e.g. `--st-base-font-weight: 400`).
- **Booleans**: become `"1"` or `"0"` (e.g. `--st-link-underline`).
- **Arrays**: become a comma-joined string (e.g. `--st-heading-font-sizes: 2.75rem,2.25rem,...`).
- If you need individual items in JS, split on `","`.
- **Missing values (`null` / `undefined`)**: become `unset` so consumers fall back to initial/inherited CSS behavior.
### Cheat sheet: the 90% variables (intent → `--st-*`)
Use this section as your starting point. It covers what most components need most of the time.
| Intent | Variables |
| ------------------------- | ---------------------------------------------------------------------- |
| App/page background | `--st-background-color` |
| Panel/card background | `--st-secondary-background-color` |
| Body text | `--st-text-color` |
| Headings | `--st-heading-color`, `--st-heading-font` |
| Primary accent / emphasis | `--st-primary-color` |
| Links | `--st-link-color`, `--st-link-underline` |
| Borders / dividers | `--st-border-color`, `--st-border-color-light` |
| Widget outline borders | `--st-widget-border-color` |
| Corner radius | `--st-base-radius`, `--st-button-radius` |
| Code blocks | `--st-code-background-color`, `--st-code-text-color`, `--st-code-font` |
### Foundation tokens (surfaces, text, borders, shape)
#### Surfaces and content colors
- `--st-background-color` (page background)
- `--st-secondary-background-color` (panels, cards, containers)
- `--st-text-color` (default text)
- `--st-heading-color` (derived heading color)
- `--st-primary-color` (brand / accent)
- `--st-link-color` (link color)
- `--st-link-underline` (boolean serialized to `"1"` / `"0"`)
#### Borders and separators
- `--st-border-color` (default borders/dividers)
- `--st-border-color-light` (derived lighter border)
- `--st-widget-border-color` (widget borders)
#### Shape (radii)
- `--st-base-radius`
- `--st-button-radius`
#### Code block colors
- `--st-code-background-color`
- `--st-code-text-color`
### Typography tokens
#### Font families
- `--st-font` (body font)
- `--st-heading-font`
- `--st-code-font`
#### Body sizing and weights
- `--st-base-font-size`
- `--st-base-font-weight` (number)
#### Headings (H1H6)
Arrays (comma-joined):
- `--st-heading-font-sizes` (array; typically 6 values for H1H6)
- `--st-heading-font-weights` (array; typically 6 values for H1H6)
Per-level convenience variables:
- `--st-heading-font-size-1`
- `--st-heading-font-size-2`
- `--st-heading-font-size-3`
- `--st-heading-font-size-4`
- `--st-heading-font-size-5`
- `--st-heading-font-size-6`
- `--st-heading-font-weight-1` (number)
- `--st-heading-font-weight-2` (number)
- `--st-heading-font-weight-3` (number)
- `--st-heading-font-weight-4` (number)
- `--st-heading-font-weight-5` (number)
- `--st-heading-font-weight-6` (number)
#### Code typography
- `--st-code-font-size`
- `--st-code-font-weight` (number)
### Data display tokens (dataframes/tables)
- `--st-dataframe-border-color`
- `--st-dataframe-header-background-color`
### Data visualization tokens (chart palettes)
Chart palette variables are **arrays serialized as comma-joined strings**:
- `--st-chart-categorical-colors` (discrete series)
- `--st-chart-sequential-colors` (low → high)
- `--st-chart-diverging-colors` (negative ↔ positive around a midpoint)
If you need the palette values in JS, split on commas:
```js
export default function (component) {
const { parentElement } = component;
const host = parentElement.host ?? parentElement;
const raw = getComputedStyle(host)
.getPropertyValue('--st-chart-categorical-colors')
.trim();
const palette = raw ? raw.split(',') : [];
}
```
### Semantic/status palette
These are best for status UI (badges, alerts, validation messages), not for primary layout surfaces.
Each “family” typically comes in three variants:
- **Base**: `--st-<name>-color`
- **Background**: `--st-<name>-background-color`
- **Text**: `--st-<name>-text-color`
Families:
- Red: `--st-red-color`, `--st-red-background-color`, `--st-red-text-color`
- Orange: `--st-orange-color`, `--st-orange-background-color`, `--st-orange-text-color`
- Yellow: `--st-yellow-color`, `--st-yellow-background-color`, `--st-yellow-text-color`
- Blue: `--st-blue-color`, `--st-blue-background-color`, `--st-blue-text-color`
- Green: `--st-green-color`, `--st-green-background-color`, `--st-green-text-color`
- Violet: `--st-violet-color`, `--st-violet-background-color`, `--st-violet-text-color`
- Gray: `--st-gray-color`, `--st-gray-background-color`, `--st-gray-text-color`
### Appendix: full variable index (alphabetical)
- `--st-background-color`
- `--st-base-font-size`
- `--st-base-font-weight`
- `--st-base-radius`
- `--st-blue-background-color`
- `--st-blue-color`
- `--st-blue-text-color`
- `--st-border-color`
- `--st-border-color-light`
- `--st-button-radius`
- `--st-chart-categorical-colors`
- `--st-chart-diverging-colors`
- `--st-chart-sequential-colors`
- `--st-code-background-color`
- `--st-code-font`
- `--st-code-font-size`
- `--st-code-font-weight`
- `--st-code-text-color`
- `--st-dataframe-border-color`
- `--st-dataframe-header-background-color`
- `--st-font`
- `--st-gray-background-color`
- `--st-gray-color`
- `--st-gray-text-color`
- `--st-green-background-color`
- `--st-green-color`
- `--st-green-text-color`
- `--st-heading-color`
- `--st-heading-font`
- `--st-heading-font-size-1`
- `--st-heading-font-size-2`
- `--st-heading-font-size-3`
- `--st-heading-font-size-4`
- `--st-heading-font-size-5`
- `--st-heading-font-size-6`
- `--st-heading-font-sizes`
- `--st-heading-font-weight-1`
- `--st-heading-font-weight-2`
- `--st-heading-font-weight-3`
- `--st-heading-font-weight-4`
- `--st-heading-font-weight-5`
- `--st-heading-font-weight-6`
- `--st-heading-font-weights`
- `--st-link-color`
- `--st-link-underline`
- `--st-orange-background-color`
- `--st-orange-color`
- `--st-orange-text-color`
- `--st-primary-color`
- `--st-red-background-color`
- `--st-red-color`
- `--st-red-text-color`
- `--st-secondary-background-color`
- `--st-text-color`
- `--st-violet-background-color`
- `--st-violet-color`
- `--st-violet-text-color`
- `--st-widget-border-color`
- `--st-yellow-background-color`
- `--st-yellow-color`
- `--st-yellow-text-color`
@@ -0,0 +1,121 @@
## Troubleshooting CCv2 components
## Contents
- Packaged assets and manifests (`asset_dir`, component key)
- Renaming / placeholder drift
- Inline strings vs file-backed assets (path heuristic)
- Globs (0 matches or multiple matches)
- Defaults, callbacks, and missing result attributes
- Keys (Python `key=` vs frontend `key`)
- Shadow DOM / `isolate_styles` surprises
- Frontend build (Vite) gotchas
- DOM clobbering (overwriting injected HTML/CSS)
### Packaged assets and manifests (`asset_dir`, component key)
#### “Component '<name>' must be declared in pyproject.toml with asset_dir to use file-backed js/css.”
You passed a **path-like** `js=`/`css=` string (like `index-*.js` or `assets/index-*.js`) but Streamlit cant find an `asset_dir` for this component key.
Fix:
- If you want **inline JS/CSS**, pass a **multi-line** string with the actual code (not a path).
- If you want **packaged assets**, ensure:
- Your wheel includes a `pyproject.toml` with `[[tool.streamlit.component.components]] ... asset_dir = ...`
- You call `st.components.v2.component("<project>.<component>", js="...", css="...")` with the matching fully-qualified key.
Important context:
- This error is expected if you test packaged wrappers via plain Python import in some environments.
- Prefer `streamlit run ...` for packaged verification because manifest discovery is part of Streamlit runtime initialization.
### Inline strings vs file-backed assets (path heuristic)
CCv2 uses a heuristic: strings that “look like” paths are treated as file references. A multi-line string is always treated as inline content.
Fix:
- Prefer triple-quoted multi-line strings for inline `html`/`css`/`js`.
- Avoid single-line minified JS/CSS in `js=`/`css=`; add a newline if you must.
### Globs (0 matches or multiple matches)
Globs must match **exactly one** file under `asset_dir`.
Fix:
- Clean the build output directory before rebuilding.
- Make your bundler output a predictable `index-<hash>.js` / `index-<hash>.css` (or `assets/index-<hash>...` if you emit into an `assets/` subdir).
- If you started from Streamlits `component-template`, run `npm run clean` from your `frontend/` directory to clear the `build/` output so `index-*.js` matches exactly one file.
### Renamed project/package still shows old template names
Symptoms:
- Old names like `streamlit-component-x` / `streamlit_component_x` remain in paths or metadata.
- Imports, manifest component keys, and registration keys no longer align.
Fix:
- Rename/update all related surfaces together:
- root `pyproject.toml` project name
- import package folder and `MANIFEST.in` paths
- `[tool.setuptools.packages.find]` and `[tool.setuptools.package-data]`
- in-package manifest (`<import_name>/pyproject.toml`)
- wrapper registration key (`"<project.name>.<component.name>"`)
- README/example imports and install commands
- Rebuild frontend and reinstall editable package after rename.
### Defaults, callbacks, and missing result attributes
#### `default={...}` doesnt apply / missing result attributes
Defaults only apply to **state keys**, and Streamlit expects those keys to be declared via `on_<key>_change` callback parameters at mount time.
Fix:
- If you pass `default={"value": ...}`, also pass `on_value_change=lambda: None`.
- For triggers, dont expect defaults; triggers are transient and default to `None`.
### Keys (Python `key=` vs frontend `key`)
- Python `key=` is the user-visible Streamlit element key.
- The frontend also receives a `key` string that is **generated by Streamlit** and is not the same as the Python `key` unless you explicitly pass the user key through `data`.
Fix:
- If your frontend needs a stable identifier, pass it in `data={"user_key": key, ...}`.
### Shadow DOM / `isolate_styles` surprises
#### Shadow DOM (`isolate_styles=True`) surprises
With `isolate_styles=True` (default):
- Your component is mounted in a **shadow root**.
- `parentElement` is a `ShadowRoot`.
- Global CSS (like Tailwind injected into the document) wont automatically style your component.
Fix:
- Keep `isolate_styles=True` for safety and use CSS variables and component-local styles.
- Use `isolate_styles=False` only when you intentionally need global styling behavior.
### Frontend build gotchas (Vite)
If you deviate from the templates Vite config (or youre wiring Vite into an existing repo), these are the common footguns:
- **Missing `base: "./"`**: relative asset URLs can break when served from Streamlits component URL path.
- **Stale build artifacts**: Vite outputs hashed filenames; if you keep old builds around, `index-*.js` can match multiple files. Clean the build dir before rebuilding.
### DOM clobbering (overwriting injected HTML/CSS)
#### Accidentally overwriting your component DOM
If you directly set `parentElement.innerHTML = ...`, you can overwrite the HTML/CSS that Streamlit injected from your `html=`/`css=` arguments.
Fix:
- Prefer `querySelector` + modifying children.
- If you need dynamic HTML, create a new child element and set **that** elements `innerHTML`.
@@ -0,0 +1,147 @@
---
name: building-streamlit-dashboards
description: Building dashboards in Streamlit. Use when creating KPI displays, metric cards, or data-heavy layouts. Covers borders, cards, responsive layouts, and dashboard composition.
license: Apache-2.0
---
# Streamlit dashboards
Compose metrics, charts, and data into clean dashboard layouts.
## Cards with borders
Use `border=True` to create visual cards. Supported on `st.container`, `st.metric`, `st.columns`, and `st.form`:
```python
# Container card
with st.container(border=True):
st.subheader("Sales Overview")
st.line_chart(sales_data)
# Metric card
st.metric("Revenue", "$1.2M", "+12%", border=True)
# Column cards
for col in st.columns(3, border=True):
with col:
st.metric("Users", "1.2k")
```
## Card labels
Add context to cards with headers or bold text:
```python
# With subheader
with st.container(border=True):
st.subheader("Monthly Trends")
st.line_chart(data)
# With bold label
with st.container(border=True):
st.markdown("**Top Products**")
st.dataframe(top_products)
```
## KPI rows
Use horizontal containers for responsive metric rows:
```python
with st.container(horizontal=True):
st.metric("Revenue", "$1.2M", "-7%", border=True)
st.metric("Users", "762k", "+12%", border=True)
st.metric("Orders", "1.4k", "+5%", border=True)
```
Horizontal containers wrap on smaller screens. Prefer them over `st.columns` for metric rows.
## Metrics with sparklines
Add trend context with `chart_data`:
```python
weekly_values = [700, 720, 715, 740, 762, 755, 780]
st.metric(
"Active Users",
"780k",
"+3.2%",
border=True,
chart_data=weekly_values,
chart_type="line", # or "bar"
)
```
Sparklines show y-values only—use for evenly-spaced data like daily/weekly snapshots.
## Dashboard layout
Combine cards into a dashboard:
```python
# KPI row
with st.container(horizontal=True):
st.metric("Revenue", "$1.2M", "-7%", border=True, chart_data=rev_trend, chart_type="line")
st.metric("Users", "762k", "+12%", border=True, chart_data=user_trend, chart_type="line")
st.metric("Orders", "1.4k", "+5%", border=True, chart_data=order_trend, chart_type="bar")
# Charts row
col1, col2 = st.columns(2)
with col1:
with st.container(border=True):
st.subheader("Revenue by Region")
st.bar_chart(region_data, x="region", y="revenue")
with col2:
with st.container(border=True):
st.subheader("Monthly Trend")
st.line_chart(monthly_data, x="month", y="value")
# Data table
with st.container(border=True):
st.subheader("Recent Orders")
st.dataframe(orders_df, hide_index=True)
```
## Sidebar filters
Put filters in the sidebar to maximize dashboard space:
```python
with st.sidebar:
date_range = st.date_input("Date range", value=(start, end))
region = st.multiselect("Region", regions, default=regions)
# Main area is all dashboard content
```
## Dashboard templates
Ready-to-use dashboard templates are available in `templates/apps/`:
| Template | Features |
|----------|----------|
| `dashboard-metrics` | Metric cards with sparklines, date filtering, focus mode |
| `dashboard-metrics-snowflake` | Same as above, with Snowflake connection |
| `dashboard-companies` | Company comparison, filterable data tables |
| `dashboard-compute` | `@st.fragment` for independent updates, popover filters |
| `dashboard-compute-snowflake` | Same as above, with Snowflake connection |
| `dashboard-feature-usage` | Feature adoption tracking, trend analysis |
| `dashboard-seattle-weather` | Weather data visualization |
| `dashboard-stock-peers` | Stock peer comparison |
| `dashboard-stock-peers-snowflake` | Same as above, with Snowflake connection |
Each template uses synthetic data that can be replaced with real queries. See `templates/apps/README.md` for setup instructions.
## Related skills
- `using-streamlit-layouts`: Columns, containers, tabs, dialogs
- `displaying-streamlit-data`: Charts, dataframes, column configuration
- `optimizing-streamlit-performance`: Caching and fragments for heavy dashboards
## References
- [st.container](https://docs.streamlit.io/develop/api-reference/layout/st.container)
- [st.metric](https://docs.streamlit.io/develop/api-reference/data/st.metric)
- [st.columns](https://docs.streamlit.io/develop/api-reference/layout/st.columns)
@@ -0,0 +1,218 @@
---
name: building-streamlit-multipage-apps
description: Building multi-page Streamlit apps. Use when creating apps with multiple pages, setting up navigation, or managing state across pages.
license: Apache-2.0
---
# Streamlit multi-page apps
Structure and navigation for apps with multiple pages.
## Directory structure
```
streamlit_app.py # Main entry point
app_pages/
home.py
analytics.py
settings.py
```
**Important:** Name your pages directory `app_pages/` (not `pages/`). Using `pages/` conflicts with Streamlit's old auto-discovery API and can cause unexpected behavior.
## Main module
```python
# streamlit_app.py
import streamlit as st
# Initialize global state (shared across pages)
if "api_client" not in st.session_state:
st.session_state.api_client = init_api_client()
# Define navigation
page = st.navigation([
st.Page("app_pages/home.py", title="Home", icon=":material/home:"),
st.Page("app_pages/analytics.py", title="Analytics", icon=":material/bar_chart:"),
st.Page("app_pages/settings.py", title="Settings", icon=":material/settings:"),
])
# App-level UI runs before page content
# Useful for shared elements like titles
st.title(f"{page.icon} {page.title}")
page.run()
```
**Note:** When you handle titles in `streamlit_app.py`, individual pages should NOT use `st.title` again.
## Navigation position
**Few pages (3-7) → Top navigation:**
```python
page = st.navigation([...], position="top")
```
Creates a clean horizontal menu. Great for simple apps. Sections are supported too—they appear as dropdowns in the top nav.
**Many pages or nested sections → Sidebar:**
```python
page = st.navigation({
"Main": [
st.Page("app_pages/home.py", title="Home"),
st.Page("app_pages/analytics.py", title="Analytics"),
],
"Admin": [
st.Page("app_pages/settings.py", title="Settings"),
st.Page("app_pages/users.py", title="Users"),
],
}, position="sidebar")
```
**Mixed: Some pages ungrouped:**
Use an empty string key `""` for pages that shouldn't be in a section. These ungrouped pages always appear first, before any named groups. Put all ungrouped pages in a single `""` key:
```python
page = st.navigation({
"": [
st.Page("app_pages/home.py", title="Home"),
st.Page("app_pages/about.py", title="About"),
],
"Analytics": [
st.Page("app_pages/dashboard.py", title="Dashboard"),
st.Page("app_pages/reports.py", title="Reports"),
],
}, position="top")
```
## Page modules
```python
# app_pages/analytics.py
import streamlit as st
# Access global state
api = st.session_state.api_client
user = st.session_state.user
# Page-specific content (title is handled in streamlit_app.py)
data = api.fetch_analytics(user.id)
st.line_chart(data)
```
## Global state
Initialize state in the main module only if it's needed across multiple pages:
```python
# streamlit_app.py
st.session_state.api = init_client()
st.session_state.user = get_user()
st.session_state.settings = load_settings()
```
**Tip:** Use `st.session_state.setdefault("key", default_value)` to initialize values only if they don't exist.
**Why main module (for global state):**
- Runs before every page
- Ensures state is initialized
- Single source of truth
## Page-specific state
Use prefixed keys for page-specific state:
```python
# app_pages/analytics.py
if "analytics_date_range" not in st.session_state:
st.session_state.analytics_date_range = default_range()
```
## Share elements between pages
Put shared UI in the entrypoint (before `page.run()`) so it appears on all pages:
```python
# streamlit_app.py
import streamlit as st
pages = [...]
page = st.navigation(pages)
# Shared title
st.title(f"{page.icon} {page.title}")
# Shared sidebar widgets
with st.sidebar:
st.selectbox("Theme", ["Light", "Dark"])
page.run()
```
## Programmatic navigation
Navigate to another page programmatically:
```python
if st.button("Go to Settings"):
st.switch_page("app_pages/settings.py")
```
Create navigation links with `st.page_link`:
```python
st.page_link("app_pages/home.py", label="Home", icon=":material/home:")
st.page_link("https://example.com", label="External", icon=":material/open_in_new:")
```
> **Note:** Prefer `st.navigation` over `st.page_link` for standard navigation. Do not use `st.page_link` to recreate the nav bar you get with `st.navigation`. Only use `st.page_link` when linking to pages from somewhere other than the sidebar, or when building a more complex navigation menu.
## Conditional pages
Show different pages based on user role, authentication, or any other condition by building the pages list dynamically:
```python
# streamlit_app.py
import streamlit as st
pages = [st.Page("app_pages/home.py", title="Home", icon=":material/home:")]
if st.user.is_logged_in:
pages.append(st.Page("app_pages/dashboard.py", title="Dashboard", icon=":material/bar_chart:"))
if st.session_state.get("is_admin"):
pages.append(st.Page("app_pages/admin.py", title="Admin", icon=":material/settings:"))
page = st.navigation(pages)
page.run()
```
Common conditions for showing/hiding pages:
- `st.user.is_logged_in` for authenticated users
- `st.session_state` flags (roles, permissions, feature flags)
- Environment variables or secrets
- Time-based access (e.g., beta features)
## Imports from pages
When importing from page files in `app_pages/`, always import from the root directory perspective:
```python
# app_pages/dashboard.py - GOOD
from utils.data import load_sales_data
# app_pages/dashboard.py - BAD (don't use relative imports)
from ..utils.data import load_sales_data
```
## References
- [Multipage apps docs](https://docs.streamlit.io/develop/concepts/multipage-apps)
- [st.navigation](https://docs.streamlit.io/develop/api-reference/navigation/st.navigation)
- [st.Page](https://docs.streamlit.io/develop/api-reference/navigation/st.page)
- [st.session_state](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.session_state)
- [st.switch_page](https://docs.streamlit.io/develop/api-reference/navigation/st.switch_page)
- [st.page_link](https://docs.streamlit.io/develop/api-reference/widgets/st.page_link)
@@ -0,0 +1,139 @@
---
name: choosing-streamlit-selection-widgets
description: Choosing the right Streamlit selection widget. Use when deciding between radio buttons, selectbox, segmented control, pills, or other option selection widgets. Helps pick the right widget for the number of options and selection type.
license: Apache-2.0
---
# Streamlit selection widgets
The right selection widget for the job. Streamlit has evolved—many old patterns are now anti-patterns.
## When to use what
Use `st.segmented_control` or `st.pills` when you want all options visible at once. Use `st.selectbox` or `st.multiselect` when options should be hidden in a dropdown.
| Widget | Best For |
|--------|----------|
| `st.segmented_control` | 2-5 options, single select, all visible |
| `st.pills` | 2-5 options, multi-select, all visible |
| `st.selectbox` | Many options, single select, dropdown |
| `st.multiselect` | Many options, multi-select, dropdown |
## Segmented control (options visible, single select)
```python
# BAD
status = st.radio("Status", ["Draft", "Published"], horizontal=True)
# GOOD
status = st.segmented_control("Status", ["Draft", "Published"])
```
For vertical layouts, `st.radio(..., horizontal=False)` is still a great choice.
Cleaner, more modern look than horizontal radio buttons.
## Pills (options visible, multi-select)
```python
# Multi-select with few options
selected = st.pills(
"Tags",
["Python", "SQL", "dbt", "Streamlit"],
selection_mode="multi"
)
```
Can also be used to mimic an "example" widget, especially with `label_visibility="collapsed"`:
```python
st.pills("Examples", ["Show me sales data", "Top customers"], label_visibility="collapsed")
```
More visual and easier to use than `st.multiselect` for small option sets.
## Selectbox (many options, single select)
```python
country = st.selectbox(
"Select a country",
["USA", "UK", "Canada", "Germany", "France", ...]
)
```
Dropdowns scale better than radio/pills for long lists.
## Multiselect (many options, multi-select)
```python
countries = st.multiselect(
"Select countries",
["USA", "UK", "Canada", "Germany", "France", ...]
)
```
## Toggle vs checkbox
Use `st.toggle` for settings that trigger changes in the app. Reserve `st.checkbox` for forms.
```python
# GOOD: Toggle for app settings
dark_mode = st.toggle("Dark mode")
show_advanced = st.toggle("Show advanced options")
# GOOD: Checkbox in forms
with st.form("signup"):
agree = st.checkbox("I agree to the terms")
st.form_submit_button("Sign up")
```
## Forms with border=False
Remove the default form border for cleaner inline forms. Keep the border for longer forms where visual grouping helps.
```python
# Inline form without border
with st.form(key="add_item", border=False):
with st.container(horizontal=True, vertical_alignment="bottom"):
st.text_input("New item", label_visibility="collapsed", placeholder="Add item")
st.form_submit_button("Add", icon=":material/add:")
# Longer form - keep the border for visual grouping
with st.form("signup"):
st.text_input("Name")
st.text_input("Email")
st.selectbox("Role", ["Admin", "User"])
st.form_submit_button("Submit")
```
## Custom options in selectbox and multiselect
Allow users to add their own options with `accept_new_options`:
```python
# Works with multiselect
tickers = st.multiselect(
"Stock tickers",
options=["AAPL", "MSFT", "GOOGL", "NVDA"],
default=["AAPL"],
accept_new_options=True,
placeholder="Choose stocks or type your own"
)
# Also works with selectbox
country = st.selectbox(
"Country",
options=["USA", "UK", "Canada"],
accept_new_options=True,
placeholder="Select or type a country"
)
```
## References
- [st.segmented_control](https://docs.streamlit.io/develop/api-reference/widgets/st.segmented_control)
- [st.pills](https://docs.streamlit.io/develop/api-reference/widgets/st.pills)
- [st.selectbox](https://docs.streamlit.io/develop/api-reference/widgets/st.selectbox)
- [st.multiselect](https://docs.streamlit.io/develop/api-reference/widgets/st.multiselect)
- [st.toggle](https://docs.streamlit.io/develop/api-reference/widgets/st.toggle)
- [st.checkbox](https://docs.streamlit.io/develop/api-reference/widgets/st.checkbox)
- [st.form](https://docs.streamlit.io/develop/api-reference/execution-flow/st.form)
@@ -0,0 +1,188 @@
---
name: connecting-streamlit-to-snowflake
description: Connecting Streamlit apps to Snowflake. Use when setting up database connections, managing secrets, or querying Snowflake from a Streamlit app.
license: Apache-2.0
---
# Streamlit Snowflake connection
Connect your Streamlit app to Snowflake the right way.
## Use st.connection
Always use `st.connection("snowflake")` instead of raw connectors.
```python
import streamlit as st
conn = st.connection("snowflake")
# Query data
df = conn.query("SELECT * FROM my_table LIMIT 100")
st.dataframe(df)
```
**Why st.connection:**
- Automatic connection pooling
- Built-in caching
- Handles reconnection
- Works with st.secrets
## Caller's rights connection (Streamlit 1.53+)
For apps running in Snowflake, use caller's rights to run queries with the viewer's permissions instead of the app owner's:
```python
conn = st.connection("snowflake", type="snowflake-callers-rights")
```
This is useful when:
- Different users should see different data based on their Snowflake roles
- You want row-level security to apply based on the viewer
- You don't want the app to have elevated permissions
## Cached queries
Use the built-in `ttl` parameter to cache query results:
```python
from datetime import timedelta
conn = st.connection("snowflake")
# Cache for 10 minutes
df = conn.query("SELECT * FROM metrics", ttl=timedelta(minutes=10))
# Cache for 1 hour
df = conn.query("SELECT * FROM reference_data", ttl=3600)
```
## Configure with st.secrets
Store credentials in `.streamlit/secrets.toml` (never commit this file).
**CRITICAL**: Derive the `account` and `host` values from the user's Snowflake CLI connection config. Run `snow connection list` and use the exact values. A wrong `account` will redirect to the wrong login page.
```toml
# .streamlit/secrets.toml
[connections.snowflake]
account = "ORGNAME-ACCTNAME" # from `snow connection list`
host = "myaccount.snowflakecomputing.com" # from `snow connection list` (include if present)
user = "your_user"
authenticator = "externalbrowser"
warehouse = "your_warehouse"
database = "your_database"
schema = "your_schema"
```
Add to `.gitignore`:
```
.streamlit/secrets.toml
```
## Parameterized queries
Use parameters to prevent SQL injection:
```python
conn = st.connection("snowflake")
# Safe: parameterized
df = conn.query(
"SELECT * FROM users WHERE region = :region",
params={"region": selected_region}
)
# UNSAFE: string formatting - don't do this
# df = conn.query(f"SELECT * FROM users WHERE region = '{selected_region}'")
```
## Write data
Use the session for write operations:
```python
conn = st.connection("snowflake")
session = conn.session()
# Write a dataframe
session.write_pandas(df, "MY_TABLE", auto_create_table=True)
# Execute statements
session.sql("INSERT INTO logs VALUES (:ts, :msg)", params={...}).collect()
```
## Multiple connections
Define multiple connections in secrets:
```toml
# .streamlit/secrets.toml
[connections.snowflake]
account = "prod_account"
# ... prod credentials
[connections.snowflake_staging]
account = "staging_account"
# ... staging credentials
```
```python
prod_conn = st.connection("snowflake")
staging_conn = st.connection("snowflake_staging")
```
## Chat with Cortex
Build a chat interface using Snowflake Cortex LLMs:
```python
import streamlit as st
from snowflake.cortex import complete
st.set_page_config(page_title="AI Assistant", page_icon=":sparkles:")
if "messages" not in st.session_state:
st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.write(msg["content"])
if prompt := st.chat_input("Ask anything"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
response = st.write_stream(
complete(
"claude-3-5-sonnet",
prompt,
session=st.connection("snowflake").session(),
stream=True,
)
)
st.session_state.messages.append({"role": "assistant", "content": response})
```
See `building-streamlit-chat-ui` for more chat patterns (avatars, suggestions, history management).
## Python 3.12+ dependency caveat
`streamlit[snowflake]` gates `snowflake-connector-python` on `python_version < "3.12"`. On Python 3.12+, the connector is silently skipped and you get `No module named 'snowflake'` at runtime. Always add `snowflake-connector-python>=3.3.0` as an explicit dependency in `pyproject.toml`:
```toml
dependencies = [
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
```
## References
- [st.connection](https://docs.streamlit.io/develop/api-reference/connections/st.connection)
- [SnowflakeConnection](https://docs.streamlit.io/develop/api-reference/connections/st.connections.snowflakeconnection)
- [st.secrets](https://docs.streamlit.io/develop/api-reference/connections/st.secrets)
@@ -0,0 +1,486 @@
---
name: creating-streamlit-themes
description: Creating and customizing Streamlit themes. Use when changing app colors, fonts, or appearance, or aligning apps to brand guidelines. Covers config.toml configuration, design principles, and CSS avoidance.
license: Apache-2.0
---
# Creating Streamlit themes
Build professional, brand-aligned themes using `.streamlit/config.toml`. This skill covers design principles and complete configuration for polished, cohesive themes.
## Theme file setup
Theme options go in Streamlit's `config.toml` under the `[theme]` section:
## Theme inheritance
Start from a built-in theme or external file:
```toml
[theme]
base = "light" # or "dark"
# base = "./my-base-theme.toml" # Local file
# base = "https://example.com/theme.toml" # Remote URL
```
When using `base`, you only need to override the values you want to change. Theme files referenced via `base` can only contain a single `[theme]` section—`[theme.light]` and `[theme.dark]` variants are not supported in external theme files.
## Color configuration
### Theme colors
```toml
[theme]
primaryColor = "#0969da" # Buttons, links, active elements
backgroundColor = "#ffffff" # Main content background
secondaryBackgroundColor = "#f6f8fa" # Widget backgrounds, code blocks
textColor = "#1F2328" # Body text
# Optional refinements
linkColor = "#0969da" # Markdown links (defaults to primaryColor)
codeTextColor = "#1F2328" # Inline code text
codeBackgroundColor = "#f6f8fa" # Code block background
borderColor = "#d0d7de" # Widget borders
```
**Design principle:** Choose a `primaryColor` dark enough to contrast with white text. Streamlit renders the text of primary buttons white against the primary color.
### Color palette
Define semantic colors for status indicators, markdown text coloring, and sparklines:
```toml
[theme]
redColor = "#cf222e"
orangeColor = "#bf8700"
yellowColor = "#dbab09"
greenColor = "#1a7f37"
blueColor = "#0969da"
violetColor = "#8250df"
grayColor = "#57606a"
```
Each color supports background and text variants (auto-derived if not set):
```toml
[theme]
greenColor = "#1a7f37"
greenBackgroundColor = "#dafbe1" # Light tint for badges
greenTextColor = "#116329" # Darkened for readability
```
### Chart colors
Define colors for Plotly, Altair, and Vega-Lite charts:
```toml
[theme]
# Categorical data (bars, pie slices, series)
chartCategoricalColors = ["#0969da", "#1a7f37", "#bf3989", "#8250df", "#cf222e", "#bf8700", "#57606a"]
# Sequential/gradient data (heatmaps) - exactly 10 colors required
chartSequentialColors = ["#f0f6fc", "#c8e1ff", "#79c0ff", "#58a6ff", "#388bfd", "#1f6feb", "#1158c7", "#0d419d", "#0a3069", "#04244a"]
```
### Dataframe styling
```toml
[theme]
dataframeBorderColor = "#d0d7de"
dataframeHeaderBackgroundColor = "#f6f8fa"
```
Ensure `textColor` is readable against `dataframeHeaderBackgroundColor`—headers use the main text color.
## Typography
### Font families
Use built-in fonts, load from Google Fonts, or define custom fonts from font files (see below):
```toml
[theme]
# Built-in options
font = "sans-serif" # or "serif" or "monospace"
# Google Fonts
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
# Font with spaces in name
font = "'IBM Plex Sans':https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"
```
### Self-hosting custom fonts
Use `[[theme.fontFaces]]` tables to load fonts via Streamlit's static file serving. Font files must be placed in a `static/` directory and served through the app—they cannot be arbitrary local file paths.
**Before adding fonts to config.toml:** Verify the font files exist in the static directory.
```toml
[[theme.fontFaces]]
family = "CustomFont"
url = "app/static/CustomFont-Regular.woff2"
weight = 400
[[theme.fontFaces]]
family = "CustomFont"
url = "app/static/CustomFont-Bold.woff2"
weight = 700
[theme]
font = "CustomFont"
```
**Attributes:** `family` (name), `url` (path to OTF/TTF/WOFF/WOFF2), `weight` (400, "200 800", or "bold"), `style` ("normal"/"italic"/"oblique"), `unicodeRange` (e.g., "U+0000-00FF").
Changes to `fontFaces` require a server restart.
### Heading and code fonts
```toml
[theme]
headingFont = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
```
### Font sizing and weight
```toml
[theme]
baseFontSize = 14 # Root size in pixels (default: 16)
baseFontWeight = 400 # Normal weight
codeFontSize = "0.875rem" # Relative to base, or use "13px"
codeFontWeight = 400
# Heading hierarchy (h1 through h6), or use a single value for all
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 500, 500, 500]
```
### Link styling
```toml
[theme]
linkUnderline = false # Remove underlines for cleaner look
```
## Border and radius
```toml
[theme]
baseRadius = "8px" # All components (none/small/medium/large/full/px/rem)
buttonRadius = "8px" # Buttons specifically (defaults to baseRadius)
showWidgetBorder = true # Show borders on unfocused widgets
showSidebarBorder = true # Show divider between sidebar and content
```
**Radius keywords:** `"none"` (0), `"small"` (4px), `"medium"` (8px), `"large"` (12px), `"full"` (pill shape).
## Sidebar customization
Style the sidebar independently:
```toml
[theme.sidebar]
backgroundColor = "#f6f8fa"
secondaryBackgroundColor = "#eaeef2"
codeBackgroundColor = "#eaeef2"
textColor = "#1F2328"
borderColor = "#d0d7de"
primaryColor = "#0969da" # Active elements in sidebar
```
## Light and dark modes
Define separate themes for each mode:
```toml
[theme.light]
primaryColor = "#0969da"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f6f8fa"
textColor = "#1F2328"
[theme.dark]
primaryColor = "#58a6ff"
backgroundColor = "#0d1117"
secondaryBackgroundColor = "#161b22"
textColor = "#e6edf3"
[theme.light.sidebar]
backgroundColor = "#f6f8fa"
[theme.dark.sidebar]
backgroundColor = "#010409"
```
Users can switch between modes in the app settings menu only if both `[theme.light]` and `[theme.dark]` are defined. A custom theme with just `[theme]` locks the app to a single mode.
## Detecting current theme
Use `st.context.theme.base` to adapt your app to the active theme. Useful for:
- Adjusting specific chart colors for better contrast
- Swapping logos or images (e.g., dark logo on light, light logo on dark)
- Styling third-party components that don't auto-adapt
- Applying conditional CSS or custom styling
```python
if st.context.theme.base == "dark":
# Do something for dark mode
```
## Design principles
### Color contrast
Ensure WCAG AA compliance (4.5:1 ratio for text):
- Light themes: Dark text (#1F2328) on light backgrounds (#ffffff)
- Dark themes: Light text (#e6edf3) on dark backgrounds (#0d1117)
- Primary colors must contrast with white button text
### Color harmony
Build cohesive palettes using these approaches:
**Monochromatic:** Single hue with varying lightness (e.g., shadcn's zinc grays)
```toml
primaryColor = "#18181B"
textColor = "#09090B"
borderColor = "#E4E4E7"
grayColor = "#71717A"
```
**Brand accent:** Neutral base with one brand color (e.g., Stripe's purple)
```toml
primaryColor = "#635bff" # Brand purple
backgroundColor = "#ffffff"
textColor = "#425466" # Neutral gray
```
**Complementary:** Brand primary with supporting accent colors
```toml
primaryColor = "#29B5E8" # Brand blue (Snowflake)
textColor = "#11567F" # Darker blue for text
greenColor = "#36B37E" # Success states
redColor = "#DE350B" # Error states
```
### Typography guidelines
- **Body text:** 14-16px, weight 400
- **Headings:** Decreasing scale from h1 (28-40px) to h6 (12-14px)
- **Code:** Monospace font, slightly smaller than body (0.85-0.875rem)
- **Font pairing:** Use the same font for body and headings for consistency, or pair complementary fonts (e.g., serif headings with sans-serif body). Code should always use a distinct monospace font.
### Visual hierarchy
Create depth with background layers:
```
Main content: #ffffff (lightest)
Secondary elements: #f6f8fa (slightly darker)
Sidebar: #f6f8fa or contrasting brand color
Code blocks: #f6f8fa (matches secondary or distinct)
```
## Example: Snowflake brand theme
Clean, professional theme with brand blue accents:
```toml
[theme]
primaryColor = "#29B5E8"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f4f9fc"
codeBackgroundColor = "#e8f4f8"
textColor = "#11567F"
linkColor = "#29B5E8"
borderColor = "#d0e8f2"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "8px"
buttonRadius = "8px"
font = "'Inter':https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "13px"
codeTextColor = "#11567F"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 500, 500, 500]
linkUnderline = false
chartCategoricalColors = ["#29B5E8", "#11567F", "#71C8E5", "#174D6A", "#A5DDF2", "#0E4D6B", "#52B8D9"]
blueColor = "#29B5E8"
greenColor = "#36B37E"
yellowColor = "#FFAB00"
redColor = "#DE350B"
violetColor = "#6554C0"
dataframeBorderColor = "#d0e8f2"
dataframeHeaderBackgroundColor = "#e8f4f8"
[theme.sidebar]
backgroundColor = "#11567F"
secondaryBackgroundColor = "#174D6A"
codeBackgroundColor = "#0E4D6B"
textColor = "#ffffff"
borderColor = "#1E6D94"
```
## Example: VS Code dark theme
Developer-focused dark theme with syntax-inspired colors:
```toml
[theme]
base = "dark"
primaryColor = "#0078d4"
backgroundColor = "#1e1e1e"
secondaryBackgroundColor = "#252526"
codeBackgroundColor = "#1e1e1e"
textColor = "#cccccc"
linkColor = "#3794ff"
borderColor = "#3c3c3c"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "4px"
buttonRadius = "4px"
font = "'Segoe UI', 'Open Sans':https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap"
codeFont = "'Fira Code':https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"
codeFontSize = "13px"
codeTextColor = "#d4d4d4"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["28px", "22px", "18px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#0078d4", "#4ec9b0", "#dcdcaa", "#ce9178", "#c586c0", "#569cd6", "#6a9955"]
blueColor = "#569cd6"
greenColor = "#6a9955"
yellowColor = "#dcdcaa"
orangeColor = "#ce9178"
violetColor = "#c586c0"
[theme.sidebar]
backgroundColor = "#252526"
secondaryBackgroundColor = "#333333"
codeBackgroundColor = "#1e1e1e"
borderColor = "#3c3c3c"
```
## Common mistakes
### Primary color too light
```toml
# BAD: White text on yellow is unreadable
primaryColor = "#FFEB3B"
# GOOD: Use a darker shade
primaryColor = "#F59E0B"
```
### Insufficient contrast
```toml
# BAD: Light gray text on white
textColor = "#CCCCCC"
backgroundColor = "#FFFFFF"
# GOOD: Dark text on light background
textColor = "#1F2328"
backgroundColor = "#FFFFFF"
```
### Mismatched backgrounds
```toml
# BAD: Secondary lighter than primary
backgroundColor = "#f6f8fa"
secondaryBackgroundColor = "#ffffff"
# GOOD: Secondary should be darker/distinct
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f6f8fa"
```
### Forgetting sidebar contrast
When using a dark sidebar with a light main section, adjust all sidebar colors—not just `textColor`:
```toml
# BAD: Only changed backgroundColor
[theme.sidebar]
backgroundColor = "#11567F"
# GOOD: Adjust all colors for dark sidebar
[theme.sidebar]
backgroundColor = "#11567F"
secondaryBackgroundColor = "#174D6A"
textColor = "#ffffff"
borderColor = "#1E6D94"
...
```
## IMPORTANT: No custom CSS unless explicitly requested
**DO NOT use custom CSS or HTML for theming.** This includes:
- `st.markdown(..., unsafe_allow_html=True)` with `<style>` or inline styles
- `st.html()` with `<style>` blocks
- Any HTML/CSS for colors, backgrounds, fonts, or visual styling
**Only use CSS if the user explicitly asks for it** (e.g., "add custom CSS", "use st.html for styling"). For brand colors, theming, and visual identity—always use `config.toml`.
Native theming is cleaner, more maintainable, and won't break with Streamlit updates.
If the user explicitly asks for CSS, use `key=` to create targetable classes:
```python
st.button("Submit", key="submit")
# Generates: .st-key-submit
st.html("""<style>.st-key-submit button { width: 100%; }</style>""")
```
**Never use CSS for theming (colors, backgrounds, fonts) unless explicitly asked. Use config.toml instead.**
## Development workflow
Most theme options update live after saving `config.toml` and rerunning. Font-related options (`fontFaces`) require a server restart.
Test your theme with: buttons (primary contrast), forms (borders, focus), dataframes (headers), code blocks, charts, and sidebar.
## Theme templates
Ready-to-use themes with bundled fonts are available in `templates/themes/`:
| Theme | Base | Primary Color | Fonts |
|-------|------|---------------|-------|
| **snowflake** | Light | `#29B5E8` (cyan) | Inter, JetBrains Mono |
| **dracula** | Dark | `#BD93F9` (purple) | Fira Sans, JetBrains Mono |
| **nord** | Dark | `#88C0D0` (frost blue) | Inter, JetBrains Mono |
| **stripe** | Light | `#635BFF` (indigo) | Inter, Source Code Pro |
| **solarized-light** | Light | `#268BD2` (blue) | Source Sans 3, Source Code Pro |
| **spotify** | Dark | `#1DB954` (green) | Inter, Fira Code |
| **github** | Light | `#0969DA` (blue) | Inter, JetBrains Mono |
| **minimal** | Dark | `#6366f1` (indigo) | Inter, JetBrains Mono |
Each theme uses Google Fonts for easy setup. See `templates/themes/README.md`.
## Related skills
- [improving-streamlit-design](../improving-streamlit-design/SKILL.md) - Visual polish with icons, badges, spacing
## References
- [Theming overview](https://docs.streamlit.io/develop/concepts/configuration/theming)
- [Colors and borders](https://docs.streamlit.io/develop/concepts/configuration/theming-customize-colors-and-borders)
- [Fonts](https://docs.streamlit.io/develop/concepts/configuration/theming-customize-fonts)
- [config.toml reference](https://docs.streamlit.io/develop/api-reference/configuration/config.toml)
- [st.context](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.context)
@@ -0,0 +1,199 @@
---
name: displaying-streamlit-data
description: Displaying charts, dataframes, and metrics in Streamlit. Use when visualizing data, configuring dataframe columns, or adding sparklines to metrics. Covers native charts, Altair, and column configuration.
license: Apache-2.0
---
# Streamlit charts & data
Present data clearly.
## Choosing display elements
| Element | Use Case |
|---------|----------|
| `st.dataframe` | Interactive exploration, sorting, filtering |
| `st.data_editor` | User-editable tables |
| `st.table` | Static display, no interaction needed |
| `st.metric` | KPIs with delta indicators |
| `st.json` | Structured data inspection |
## Native charts first
Prefer Streamlit's native charts for simple cases.
```python
st.line_chart(df, x="date", y="revenue")
st.bar_chart(df, x="category", y="count")
st.scatter_chart(df, x="age", y="salary")
st.area_chart(df, x="date", y="value")
```
Native charts support additional parameters: `color` for series grouping, `stack` for bar/area stacking, `size` for scatter point sizing, `horizontal` for horizontal bars. See the [chart API reference](https://docs.streamlit.io/develop/api-reference/charts) for full options.
## Human-readable labels
Use clear labels—not column names or abbreviations. Skip `x_label`/`y_label` if the column names are already readable.
```python
# BAD: cryptic column names without labels
st.line_chart(df, x="dt", y="rev")
# GOOD: readable columns, no labels needed
st.line_chart(df, x="date", y="revenue")
# GOOD: cryptic columns, add labels
st.line_chart(df, x="dt", y="rev", x_label="Date", y_label="Revenue")
```
## Altair for complex charts
Use Altair when you need more control. Altair is bundled with Streamlit (no extra install), while Plotly requires an additional package. Pick one and stay consistent throughout your app.
```python
import altair as alt
chart = alt.Chart(df).mark_line().encode(
x=alt.X("date:T", title="Date"),
y=alt.Y("revenue:Q", title="Revenue ($)"),
color="region:N"
)
st.altair_chart(chart)
```
**When to use Altair:**
- Custom axis formatting
- Multiple series with legends
- Interactive tooltips
- Layered visualizations
## Dataframe column configuration
Use `column_config` where it adds value—formatting currencies, showing progress bars, displaying links or images. Don't add config just for labels or tooltips that don't meaningfully improve readability. Works with both `st.dataframe` and `st.data_editor`.
```python
st.dataframe(
df,
column_config={
"revenue": st.column_config.NumberColumn(
"Revenue",
format="$%.2f"
),
"completion": st.column_config.ProgressColumn(
"Progress",
min_value=0,
max_value=100
),
"url": st.column_config.LinkColumn("Website"),
"logo": st.column_config.ImageColumn("Logo"),
"created_at": st.column_config.DatetimeColumn(
"Created",
format="MMM DD, YYYY"
),
"internal_id": None, # Hide non-essential columns
},
hide_index=True,
)
```
**Note on hiding columns:** Setting a column to `None` hides it from the UI, but the data is still sent to the frontend. For truly sensitive data, pre-filter the DataFrame before displaying.
**Dataframe best practices:**
- **Hide useless index:** `hide_index=True`
- **Or make index meaningful:** `df = df.set_index("customer_name")` before displaying
- **Hide internal/technical columns:** Set column to `None` in config (but pre-filter for sensitive data)
- **Use visual column types where they help:** sparklines for trends, progress bars for completion, images for logos
**Column types:**
- `AreaChartColumn` → Area sparklines
- `BarChartColumn` → Bar sparklines
- `CheckboxColumn` → Boolean as checkbox
- `DateColumn` → Date only (no time)
- `DatetimeColumn` → Dates with formatting
- `ImageColumn` → Images
- `JSONColumn` → Display JSON objects
- `LineChartColumn` → Sparkline charts
- `LinkColumn` → Clickable links
- `ListColumn` → Display lists/arrays
- `MultiselectColumn` → Multi-value selection
- `NumberColumn` → Numbers with formatting
- `ProgressColumn` → Progress bars
- `SelectboxColumn` → Editable dropdown
- `TextColumn` → Text with formatting
- `TimeColumn` → Time only (no date)
## Pinned columns
Keep important columns visible while scrolling horizontally:
```python
st.dataframe(
df,
column_config={
"Title": st.column_config.TextColumn(pinned=True), # Always visible
"Rating": st.column_config.ProgressColumn(min_value=0, max_value=10),
},
hide_index=True,
)
```
## Data editor
Use `st.data_editor` when users need to edit data directly:
```python
edited_df = st.data_editor(
df,
num_rows="dynamic", # Allow adding/deleting rows
column_config={
"status": st.column_config.SelectboxColumn(
"Status",
options=["pending", "approved", "rejected"]
),
},
)
# React to edits
if not edited_df.equals(df):
save_changes(edited_df)
```
## JSON display
For structured data inspection. Accepts dicts, lists, or any JSON-serializable object:
```python
st.json({"name": "John", "scores": [95, 87, 92]})
```
## Sparklines in metrics
Add `chart_data` and `chart_type` to metrics for visual context.
```python
values = [700, 720, 715, 740, 762, 755, 780]
st.metric(
label="Developers",
value="762k",
delta="-7.42% (MoM)",
delta_color="inverse",
chart_data=values,
chart_type="line" # or "bar"
)
```
**Note:** Sparklines only show y-values and ignore x-axis spacing. Use them for evenly-spaced data (like daily or weekly snapshots). For irregularly-spaced time series, use a proper chart instead.
See `building-streamlit-dashboards` for composing metrics into dashboard layouts.
## References
- [st.dataframe](https://docs.streamlit.io/develop/api-reference/data/st.dataframe)
- [st.data_editor](https://docs.streamlit.io/develop/api-reference/data/st.data_editor)
- [st.column_config](https://docs.streamlit.io/develop/api-reference/data/st.column_config)
- [st.metric](https://docs.streamlit.io/develop/api-reference/data/st.metric)
- [st.json](https://docs.streamlit.io/develop/api-reference/data/st.json)
- [st.line_chart](https://docs.streamlit.io/develop/api-reference/charts/st.line_chart)
- [st.bar_chart](https://docs.streamlit.io/develop/api-reference/charts/st.bar_chart)
- [st.altair_chart](https://docs.streamlit.io/develop/api-reference/charts/st.altair_chart)
@@ -0,0 +1,191 @@
---
name: improving-streamlit-design
description: Improving visual design in Streamlit apps. Use when polishing apps with icons, badges, spacing, or text styling. Covers Material icons, badge syntax, divider alternatives, and text casing conventions.
license: Apache-2.0
---
# Streamlit visual design
Small touches that make apps feel polished.
**Related skills:** Visual design works hand-in-hand with other skills:
- `choosing-streamlit-selection-widgets` → Choosing the right widget (segmented control, pills, toggle)
- `displaying-streamlit-data` → Column config, sparklines, bordered metrics
- `using-streamlit-layouts` → Containers, alignment, dashboard cards
## Page config
Set browser tab title, icon, and layout. Place this at the top of your script to avoid visual blinking:
```python
st.set_page_config(
page_title="My Dashboard",
page_icon=":material/analytics:",
layout="wide", # Use "wide" for dashboards with lots of data
)
```
**Layout options:**
- `layout="centered"` (default) → Best for most apps, content is constrained to a readable width
- `layout="wide"` → Full-width, good for dashboards and data-heavy apps
## App logo
Add a logo to the sidebar/header:
```python
st.logo("logo.png")
```
## Icons over emojis
Use Material icons for a cleaner, more professional look.
```python
# GOOD: Material icons
st.markdown(":material/settings:")
st.markdown(":material/calendar_today:")
st.markdown(":material/dashboard:")
st.markdown(":material/person:")
# SPARINGLY: Emojis for special occasions
st.markdown("Celebration! 🎉")
```
Format: `:material/icon_name:`
Find icons: https://fonts.google.com/icons
**Popular icons by category:**
| Category | Icons |
|----------|-------|
| Navigation | `home`, `arrow_back`, `menu`, `settings`, `search` |
| Actions | `send`, `play_arrow`, `refresh`, `download`, `upload`, `save`, `delete`, `edit` |
| Status | `check_circle`, `error`, `warning`, `info`, `pending` |
| Data | `table_chart`, `bar_chart`, `analytics`, `query_stats`, `database` |
| Content | `chat`, `code`, `description`, `article`, `folder` |
| UI | `visibility`, `build`, `tune`, `filter_list` |
## Badges for status
For standalone badges:
```python
st.badge("Active", icon=":material/check:", color="green")
st.badge("Pending", icon=":material/schedule:", color="orange")
st.badge("Deprecated", color="red")
```
For inline badges in text:
```python
st.markdown("""
:green-badge[Active] :orange-badge[Pending] :red-badge[Deprecated] :blue-badge[New]
""")
```
Avoid the old verbose syntax:
```python
# OLD (still works but cluttered)
st.markdown(":orange-background[:orange[Pending]]")
```
## Spacing: remove dividers
Dividers (`st.divider()` or `---`) look heavy. Just remove them—Streamlit's default spacing is usually enough.
```python
# BAD
st.header("Section 1")
st.write("Content")
st.divider() # Too heavy
st.header("Section 2")
# GOOD
st.header("Section 1")
st.write("Content")
st.header("Section 2")
```
If you genuinely need spacing:
```python
st.space("small") # Small gap
st.space("medium") # Medium gap
st.space("large") # Large gap
st.space(50) # Custom pixels for fine-tuning
```
**Don't** systematically replace dividers with `st.space()`—it can look weird too.
## Sentence casing
Use sentence casing for titles and labels. Title Case Feels Shouty.
```python
# GOOD
st.title("Upload your data")
st.selectbox("Select a region", options)
st.button("Save changes")
# BAD
st.title("Upload Your Data")
st.selectbox("Select A Region", options)
```
## Caption over info
`st.info()` is too heavy for simple informational text.
```python
# GOOD: Lighter
st.caption("Data last updated 5 minutes ago")
# BAD: Too heavy
st.info("Data last updated 5 minutes ago")
```
**When to use what:**
- `st.caption` → Simple info, metadata, timestamps
- `st.info` → Important instructions
- `st.warning` → Caution, potential issues
- `st.error` → Errors that block progress
- `st.success` → Confirmation of action
- `st.toast` → Lightweight confirmation that auto-dismisses
## Text alignment
Use `text_alignment` for text elements:
```python
st.title("Centered title", text_alignment="center")
st.write("Right aligned", text_alignment="right")
st.caption("Justified text", text_alignment="justify")
```
Options: `"left"` (default), `"center"`, `"right"`, `"justify"`
**Note:** `horizontal_alignment` on containers positions elements but also sets their `text_alignment`. If you need different text alignment within a horizontally-aligned container, override `text_alignment` on the text element itself.
## Icons in callouts and expanders
Material icons can make callouts and expanders look nicer:
```python
st.info("Processing complete", icon=":material/check_circle:")
st.warning("Rate limit approaching", icon=":material/warning:")
st.error("Connection failed", icon=":material/error:")
st.success("Saved!", icon=":material/thumb_up:")
with st.expander("Settings", icon=":material/settings:"):
st.write("Configure your preferences")
```
Other elements like `st.button` and `st.tabs` also support icons—worth considering when it adds clarity.
## References
- [st.set_page_config](https://docs.streamlit.io/develop/api-reference/configuration/st.set_page_config)
- [st.logo](https://docs.streamlit.io/develop/api-reference/media/st.logo)
- [st.badge](https://docs.streamlit.io/develop/api-reference/text/st.badge)
- [st.space](https://docs.streamlit.io/develop/api-reference/layout/st.space)
- [st.markdown](https://docs.streamlit.io/develop/api-reference/text/st.markdown)
- [st.toast](https://docs.streamlit.io/develop/api-reference/status/st.toast)
@@ -0,0 +1,323 @@
---
name: optimizing-streamlit-performance
description: Optimizing Streamlit app performance. Use when apps are slow, rerunning too often, or loading heavy content. Covers caching, fragments, and static vs dynamic widget choices.
license: Apache-2.0
---
# Streamlit performance
Performance is the biggest win. Without caching and fragments, your app reruns everything on every interaction.
## Caching
### @st.cache_data for data
Use for any function that loads or computes data.
```python
# BAD: Recomputes on every rerun
def load_data(path):
return pd.read_csv(path)
# GOOD: Cached
@st.cache_data
def load_data(path):
return pd.read_csv(path)
```
### @st.cache_resource for connections
Use for connections, API clients, ML models—objects that can't be serialized.
```python
@st.cache_resource
def get_connection():
return st.connection("snowflake")
@st.cache_resource
def load_model():
return torch.load("model.pt")
```
**Critical warning:** Never mutate `@st.cache_resource` returns—changes affect all users:
```python
# BAD: Mutating shared resource
@st.cache_resource
def get_config():
return {"setting": "default"}
config = get_config()
config["setting"] = "custom" # Affects ALL users!
# GOOD: Copy before modifying
config = get_config().copy()
config["setting"] = "custom"
```
**Cleanup with `on_release`:** Clean up resources when evicted from cache:
```python
def cleanup_connection(conn):
conn.close()
@st.cache_resource(on_release=cleanup_connection)
def get_database():
return create_connection()
```
### TTL for fresh data
```python
@st.cache_data(ttl="5m") # 5 minutes
def get_metrics():
return api.fetch()
@st.cache_data(ttl="1h") # 1 hour
def load_reference_data():
return pd.read_csv("large_reference.csv")
```
**Guidelines:**
- Real-time dashboards → `ttl="1m"` or less
- Metrics/reports → `ttl="5m"` to `ttl="15m"`
- Reference data → `ttl="1h"` or more
- Static data → No TTL
### Prevent unbounded cache growth
**Important:** Caches without `ttl` or `max_entries` can grow indefinitely and cause memory issues. For any cached function that stores changing objects (user-specific data, parameterized queries), set limits:
```python
# BAD: Unbounded cache - memory will grow indefinitely
@st.cache_data
def get_user_data(user_id):
return fetch_user(user_id)
# GOOD: Bounded cache with TTL
@st.cache_data(ttl="1h")
def get_user_data(user_id):
return fetch_user(user_id)
# GOOD: Bounded cache with max entries
@st.cache_data(max_entries=100)
def get_user_data(user_id):
return fetch_user(user_id)
```
Use `ttl` for time-based expiration OR `max_entries` for size-based limits. You usually don't need both.
### Caching anti-patterns
**Don't cache functions that read widgets:**
```python
# BAD: Widget inside cached function
@st.cache_data
def filtered_data():
query = st.text_input("Query") # Widget inside cached function!
return df[df["name"].str.contains(query)]
# GOOD: Pass widget values as parameters
@st.cache_data
def filtered_data(query: str):
return df[df["name"].str.contains(query)]
query = st.text_input("Query")
result = filtered_data(query)
```
**Cache at the right granularity:**
```python
# BAD: Caching too much - new cache entry per filter value
@st.cache_data
def get_and_filter_data(filter_value):
data = load_all_data() # Expensive!
return data[data["col"] == filter_value]
# GOOD: Cache the expensive part, filter separately
@st.cache_data(ttl="1h")
def load_all_data():
return fetch_from_database()
data = load_all_data()
filtered = data[data["col"] == filter_value]
```
## Fragments
Use `@st.fragment` to isolate reruns for self-contained UI pieces.
```python
# BAD: Full app reruns
st.metric("Users", get_count())
if st.button("Refresh"):
st.rerun()
# GOOD: Only fragment reruns
@st.fragment
def live_metrics():
st.metric("Users", get_count())
st.button("Refresh")
live_metrics()
```
For auto-refreshing metrics, use `run_every`:
```python
@st.fragment(run_every="30s")
def auto_refresh_metrics():
st.metric("Users", get_count())
auto_refresh_metrics()
```
Use for: live metrics, refresh buttons, interactive charts that don't affect global state.
## Forms to batch interactions
By default, every widget interaction triggers a full rerun. Use `st.form` to batch multiple inputs and only rerun on submit.
```python
# BAD: Reruns on every keystroke and selection
name = st.text_input("Name")
email = st.text_input("Email")
role = st.selectbox("Role", ["Admin", "User"])
# GOOD: Single rerun when user clicks Submit
with st.form("user_form"):
name = st.text_input("Name")
email = st.text_input("Email")
role = st.selectbox("Role", ["Admin", "User"])
submitted = st.form_submit_button("Submit")
if submitted:
save_user(name, email, role)
```
Use `border=False` for seamless inline forms that don't look like forms:
```python
with st.form("search", border=False):
with st.container(horizontal=True):
query = st.text_input("Search", label_visibility="collapsed")
st.form_submit_button(":material/search:")
```
**When to use forms:**
- Multiple related inputs (signup, filters, settings)
- Text inputs where typing triggers expensive operations
- Any UI where "submit" semantics make sense
**When NOT to use forms:** If inputs depend on each other (e.g., selecting a country should update available cities), forms won't work since there's no rerun until submit.
## Conditional rendering
**This is critical and often missed.**
Layout containers like `st.tabs`, `st.expander`, and `st.popover` always render all their content, even when hidden or collapsed.
To render content only when needed, use elements like `st.segmented_control`, `st.toggle`, or `@st.dialog` with conditional logic:
```python
# BAD: Heavy content loads even when tab not visible
tab1, tab2 = st.tabs(["Light", "Heavy"])
with tab2:
expensive_chart() # Always computed!
# GOOD: Content only loads when selected
view = st.segmented_control("View", ["Light", "Heavy"])
if view == "Heavy":
expensive_chart() # Only computed when selected
```
```python
# BAD: Expander content always loads
with st.expander("Advanced options"):
heavy_computation() # Runs even when collapsed!
# GOOD: Toggle controls loading
if st.toggle("Show advanced options"):
heavy_computation() # Only runs when toggled on
```
## Pre-computation
Move expensive work outside the main flow:
- Compute aggregations in SQL/dbt, not Python
- Pre-compute metrics in scheduled jobs
- Use materialized views for complex queries
## Large data handling
### For datasets under ~100M rows
```python
@st.cache_data
def load_data():
return pd.read_parquet("large_file.parquet")
```
### For very large datasets (over ~100M rows)
> **Note:** This is only an escape hatch when serialization becomes too slow. In most cases, data this large shouldn't be loaded entirely into memory—prefer using a database that queries and loads data on demand.
`@st.cache_data` uses pickle which slows with huge data. Use `@st.cache_resource` instead:
```python
@st.cache_resource # No serialization overhead
def load_huge_data():
return pd.read_parquet("huge_file.parquet")
# WARNING: Don't mutate the returned DataFrame!
```
### Sampling for exploration
When exploring large datasets, load a random sample instead of the full data:
```python
@st.cache_data(ttl="1h")
def load_sample(n=10000):
df = pd.read_parquet("huge.parquet")
return df.sample(n=n)
```
## Multithreading
Custom threads cannot call Streamlit commands (no session context).
```python
import threading
def fetch_in_background(url, results, index):
results[index] = requests.get(url).json() # No st.* calls!
# Collect results, then display in main thread
results = [None] * len(urls)
threads = [
threading.Thread(target=fetch_in_background, args=(url, results, i))
for i, url in enumerate(urls)
]
for t in threads:
t.start()
for t in threads:
t.join()
# Now display in main thread
for result in results:
st.write(result)
```
**Prefer alternatives when possible:**
- `@st.cache_data` for expensive computations
- `@st.fragment(run_every="5s")` for periodic updates
## References
- [Caching overview](https://docs.streamlit.io/develop/concepts/architecture/caching)
- [st.cache_data](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_data)
- [st.cache_resource](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_resource)
- [st.fragment](https://docs.streamlit.io/develop/api-reference/execution-flow/st.fragment)
- [st.form](https://docs.streamlit.io/develop/api-reference/execution-flow/st.form)
@@ -0,0 +1,91 @@
---
name: organizing-streamlit-code
description: Organizing Streamlit code for maintainability. Use when structuring apps with separate modules and utilities. Covers separation of concerns, keeping UI code clean, and import patterns.
license: Apache-2.0
---
# Streamlit code organization
For most simple apps, keep everything in one file—it's cleaner and more straightforward. The app file should read like a normal Python script for data processing, with a few Streamlit commands sprinkled in.
Name the main file `streamlit_app.py` (Streamlit's default).
## When to split
**Keep in one file (most apps):**
- Apps under ~1000 lines
- One-off scripts and prototypes
- Apps where logic is straightforward
**Consider splitting when:**
- Data processing is complex (50+ lines of non-UI code)
- Multiple pages share logic
- You want to test business logic separately
If splitting makes sense, here's how to organize it.
## Directory structure
```
my-app/
├── streamlit_app.py # Main entry point
├── app_pages/ # Page UI modules
│ ├── dashboard.py
│ └── settings.py
└── utils/ # Business logic & helpers
├── data.py
└── api.py
```
## Separating UI from logic
When you do split, keep Streamlit files focused on UI and move complex logic to utility modules:
```python
# streamlit_app.py - UI-focused
import streamlit as st
from utils.data import load_sales_data, compute_metrics
st.title("Sales Dashboard")
start = st.date_input("Start")
end = st.date_input("End")
data = load_sales_data(start, end)
metrics = compute_metrics(data)
st.metric("Revenue", f"${metrics['revenue']:,.0f}")
st.dataframe(data)
```
## Avoid if __name__ == "__main__"
Streamlit apps run the entire file on each interaction. Don't use the main guard in Streamlit files.
```python
# BAD - don't do this in streamlit_app.py or pages
if __name__ == "__main__":
main()
# GOOD - just put the code directly
import streamlit as st
st.title("My App")
```
The main guard is fine in utility modules for quick testing:
```python
# utils/data.py
def load_data(path):
...
# Optional: test this module directly with `python utils/data.py`
if __name__ == "__main__":
print(load_data("test.csv"))
```
## References
- [Multipage apps](https://docs.streamlit.io/develop/concepts/multipage-apps)
- [st.cache_data](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.cache_data)
@@ -0,0 +1,128 @@
---
name: setting-up-streamlit-environment
description: Setting up Python environments for Streamlit apps. Use when creating a new project or managing dependencies. Covers uv for dependency management and running apps.
license: Apache-2.0
---
# Streamlit environment
Use whatever dependency management the project already has (pip, poetry, conda, etc.). If starting fresh and uv is available, it's a good default—fast, reliable, and creates isolated environments automatically.
If uv is not installed, ask the user before installing it.
## CRITICAL: Always Use Latest Streamlit
**Always specify `streamlit>=1.53.0`** (or latest) in dependencies. Many Streamlit features and patterns in these skills require recent versions. Older streamlit versions will cause errors with:
- Material icons (`:material/icon_name:`)
- `st.pills()`, `st.segmented_control()`
- Modern caching decorators
- Navigation APIs
When setting up a new project or fixing an existing one, **always check and update the streamlit version**.
## Using uv
If uv is available, here's how to set up a Streamlit project.
### Quick start (venv only)
For simple apps, just create a virtual environment:
```bash
uv venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
uv pip install streamlit
```
Run with:
```bash
streamlit run streamlit_app.py
```
## Full project setup
For larger projects or when you need reproducible builds:
```bash
uv init my-streamlit-app
cd my-streamlit-app
uv add streamlit
```
This creates:
- `pyproject.toml` with dependencies
- `uv.lock` for reproducible builds
- `.venv/` virtual environment
Run with:
```bash
uv run streamlit run streamlit_app.py
```
## With options
Avoid setting options unless you have a specific reason:
```bash
streamlit run streamlit_app.py --server.headless true # Only for automated/CI environments
```
## Add dependencies
```bash
# With venv approach
uv pip install plotly snowflake-connector-python
# With full project (uv init)
uv add plotly snowflake-connector-python
```
## Project structure
Keep it simple. For most apps:
```
my-streamlit-app/
├── .venv/
└── streamlit_app.py
```
Only add more when needed:
- `app_pages/` → Only for multi-page apps
- `.streamlit/config.toml` → Only if customizing theme or settings
- `.streamlit/secrets.toml` → Only if using secrets (add to `.gitignore`)
- `pyproject.toml` → Only if using `uv init` for reproducible builds
## Convention
Name your main file `streamlit_app.py` for consistency. This is what Streamlit expects by default.
**What goes in the main module:**
- When using navigation: it's a router that defines pages and runs them
- When there's no navigation: it's the home page with your main content
## pyproject.toml Example
```toml
[project]
name = "my-streamlit-app"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"streamlit>=1.53.0",
"plotly>=5.0.0",
"snowflake-connector-python>=3.0.0",
]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
]
```
## References
- [uv documentation](https://docs.astral.sh/uv/)
- [Streamlit installation](https://docs.streamlit.io/get-started/installation)
@@ -0,0 +1,165 @@
---
name: using-streamlit-cli
description: Documents Streamlit CLI commands for running apps, managing configuration, and diagnostics. Use when starting Streamlit apps, configuring runtime options, or troubleshooting CLI issues.
---
# Using the Streamlit CLI
The Streamlit CLI is the primary tool for running Streamlit applications and managing configuration. This skill covers all essential commands and configuration options.
## Running Streamlit apps
### Basic syntax
```bash
streamlit run [<entrypoint>] [-- config options] [script args]
```
### Entrypoint options
| Argument | Behavior |
|----------|----------|
| (none) | Looks for `streamlit_app.py` in current directory |
| Directory path | Runs `streamlit_app.py` within that directory |
| File path | Runs the specified file directly |
| URL | Runs a remote script (e.g., from GitHub) |
### Examples
```bash
# Run default app in current directory
streamlit run
# Run a specific file
streamlit run app.py
# Run from a URL
streamlit run https://raw.githubusercontent.com/streamlit/demo-uber-nyc-pickups/master/streamlit_app.py
# Alternative: run as Python module (useful for IDE configuration)
python -m streamlit run app.py
```
### Running with `uv` (recommended)
Use `uv run` to run Streamlit in a virtual environment with automatic dependency management:
```bash
# Run with uv (automatically uses/creates virtual environment)
uv run streamlit run app.py
# With configuration options
uv run streamlit run app.py --server.headless=true
# With script arguments
uv run streamlit run app.py -- arg1 arg2
```
Using `uv run` is the recommended approach because it:
- Automatically manages virtual environments
- Resolves and installs dependencies from `pyproject.toml`
- Ensures reproducible environments across machines
- Avoids manual activation/deactivation of virtual environments
## Setting configuration with `streamlit run`
Configuration options follow the pattern `--<section>.<option>=<value>` and must come after the script name.
> **Recommendation:** For persistent configuration, use `.streamlit/config.toml` in your project directory instead of command-line flags. This keeps your run command simple and makes configuration easier to manage and share with your team.
### Examples
```bash
streamlit run app.py --server.port=8080
streamlit run app.py --server.headless=true
streamlit run app.py --server.runOnSave=true
streamlit run app.py --server.address=0.0.0.0
streamlit run app.py --client.showErrorDetails=false
streamlit run app.py --theme.primaryColor=blue
```
### Combining multiple options
```bash
streamlit run app.py \
--server.port=8080 \
--server.headless=true \
--theme.primaryColor=blue \
--client.showErrorDetails=false
```
## Passing arguments to your script
Script arguments come after configuration options. Use `sys.argv` to access them:
```bash
streamlit run app.py -- arg1 arg2 "arg with spaces"
```
In your script:
```python
import sys
# sys.argv[0] = script path
# sys.argv[1:] = your arguments
args = sys.argv[1:]
```
## Other CLI commands
### View configuration
```bash
# Show all current configuration settings
streamlit config show
```
### Cache management
```bash
# Clear all cached data from disk
streamlit cache clear
```
### Diagnostics and help
```bash
# Show installed version
streamlit version
# List all available commands
streamlit help
# Open documentation in browser
streamlit docs
```
### Project scaffolding
```bash
# Create starter files for a new project
streamlit init
```
### Demo app
```bash
# Launch the Streamlit demo application
streamlit hello
```
## Configuration precedence
Configuration can be set in multiple places. Order of precedence (highest to lowest):
1. **Command-line flags** (`--server.port=8080`)
2. **Environment variables** (`STREAMLIT_SERVER_PORT=8080`)
3. **Local config** (`.streamlit/config.toml` in project directory)
4. **Global config** (`~/.streamlit/config.toml`)
## References
- [Run your app](https://docs.streamlit.io/develop/concepts/architecture/run-your-app) - Concepts and methods for running Streamlit apps
- [config.toml](https://docs.streamlit.io/develop/api-reference/configuration/config.toml) - Complete configuration options reference
- [CLI reference](https://docs.streamlit.io/develop/api-reference/cli) - Full CLI command documentation
@@ -0,0 +1,170 @@
---
name: using-streamlit-custom-components
description: Using third-party Streamlit custom components. Use when extending Streamlit with community packages. Covers installation, popular custom components, and when to use them.
license: Apache-2.0
---
# Streamlit custom components
Extend Streamlit with third-party custom components from the community.
## What are custom components?
Custom components are standalone Python libraries that add features not in Streamlit's core API. They're built by the community and can be installed like any Python package.
## Installation
Install using the PyPI package name (not the repo name—they can differ):
```bash
uv add <pypi-package-name>
```
Then import according to the component's documentation. The import name often differs from the package name too.
## Use with caution
Components are not maintained by Streamlit. Before adopting:
- **Check maintenance** - Is it actively maintained? Recent commits?
- **Check compatibility** - Does it work with your Streamlit version?
- **Check popularity** - GitHub stars, downloads, community usage
- **Consider alternatives** - Can you achieve this with core Streamlit?
Custom components can break when Streamlit updates, so prefer core features when possible.
## Popular custom components
### streamlit-keyup
Text input that fires on every keystroke instead of waiting for enter/blur. Useful for live search.
- **Repo:** https://github.com/blackary/streamlit-keyup
- **Docs:** https://pypi.org/project/streamlit-keyup/
```bash
uv add streamlit-keyup
```
```python
from st_keyup import st_keyup
query = st_keyup("Search", debounce=300) # 300ms debounce
filtered = df[df["name"].str.contains(query, case=False)]
st.dataframe(filtered)
```
### streamlit-bokeh
Official replacement for `st.bokeh_chart` (removed from Streamlit API). Maintained by Streamlit.
- **Repo:** https://github.com/streamlit/streamlit-bokeh
- **Docs:** https://pypi.org/project/streamlit-bokeh/
```bash
uv add streamlit-bokeh
```
```python
from bokeh.plotting import figure
from streamlit_bokeh import streamlit_bokeh
p = figure(title="Simple Line", x_axis_label="x", y_axis_label="y")
p.line([1, 2, 3, 4, 5], [6, 7, 2, 4, 5], line_width=2)
streamlit_bokeh(p)
```
### streamlit-aggrid
Interactive dataframes with sorting, filtering, cell editing, grouping, and pivoting. Use when you need customization beyond what `st.dataframe` and `st.data_editor` offer.
- **Repo:** https://github.com/PablocFonseca/streamlit-aggrid
- **Docs:** https://pypi.org/project/streamlit-aggrid/
```bash
uv add streamlit-aggrid
```
```python
from st_aggrid import AgGrid
AgGrid(df, editable=True, filter=True)
```
**When to use aggrid over st.dataframe:**
- Interactive row grouping and pivoting
- Advanced filtering and sorting UI
- Complex cell editing workflows
- Custom cell renderers
### streamlit-folium
Interactive maps powered by Folium.
- **Repo:** https://github.com/randyzwitch/streamlit-folium
- **Docs:** https://folium.streamlit.app/
```bash
uv add streamlit-folium
```
```python
import folium
from streamlit_folium import st_folium
m = folium.Map(location=[37.7749, -122.4194], zoom_start=12)
st_folium(m, width=700)
```
### pygwalker
Tableau-like drag-and-drop data exploration.
- **Repo:** https://github.com/Kanaries/pygwalker
- **Docs:** https://docs.kanaries.net/pygwalker
```bash
uv add pygwalker
```
```python
import pygwalker as pyg
pyg.walk(df, env="Streamlit")
```
### streamlit-extras
A collection of community utilities. Cherry-pick what you need.
- **Repo:** https://github.com/arnaudmiribel/streamlit-extras
- **Docs:** https://extras.streamlit.app/
```bash
uv add streamlit-extras
```
```python
from streamlit_extras.image_selector import image_selector
# Let users click on regions of an image
selection = image_selector(image, selections=["Region A", "Region B"])
```
```python
from streamlit_extras.vertical_slider import vertical_slider
# A vertical slider widget
value = vertical_slider("Volume", min_value=0, max_value=100, default_value=50)
```
## Discover more
Browse the custom component gallery: https://streamlit.io/components
Filter by category, popularity, and recency to find custom components for your use case.
## References
- [Components Gallery](https://streamlit.io/components)
- [Build a custom component](https://docs.streamlit.io/develop/concepts/custom-components)
@@ -0,0 +1,229 @@
---
name: using-streamlit-layouts
description: Structuring Streamlit app layouts. Use when placing content in sidebars, columns, containers, or dialogs. Covers sidebar usage, column limits, horizontal containers, dialogs, and bordered cards.
license: Apache-2.0
---
# Streamlit layout
How you structure your app affects usability more than you think.
## Sidebar: navigation + global filters only
The sidebar should only contain navigation and app-level filters. Main content goes in the main area.
```python
# GOOD
with st.sidebar:
date_range = st.date_input("Date range")
region = st.selectbox("Region", ["All", "US", "EU", "APAC"])
st.caption("App v1.2.3")
```
```python
# BAD: Too much content in sidebar
with st.sidebar:
st.title("Dashboard")
st.dataframe(df) # Don't put main content here
st.bar_chart(data)
```
**What goes in sidebar:**
- Global filters (date range, user selection, region)
- App info (version, feedback link)
**What stays out:**
- Main content, charts, tables, results
## Columns: max 4, set alignment
Don't use too many columns—they get cramped.
```python
# GOOD
col1, col2 = st.columns(2)
# Custom widths (ratios)
col1, col2 = st.columns([2, 1]) # 2:1 ratio
# OK with alignment
cols = st.columns(4, vertical_alignment="center")
# BAD: Too many, cramped
col1, col2, col3, col4, col5, col6 = st.columns(6)
```
## Horizontal containers for button groups
Use `st.container(horizontal=True)` instead of columns for button groups:
```python
with st.container(horizontal=True):
st.button("Cancel")
st.button("Save")
st.button("Submit")
```
## Aligning elements
Use `horizontal_alignment` on containers to position elements:
```python
# Center elements
with st.container(horizontal_alignment="center"):
st.image("logo.png", width=200)
st.title("Welcome")
# Right-align elements
with st.container(horizontal_alignment="right"):
st.button("Settings", icon=":material/settings:")
# Distribute evenly (great for button groups)
with st.container(horizontal=True, horizontal_alignment="distribute"):
st.button("Cancel")
st.button("Save")
st.button("Submit")
```
Options: `"left"` (default), `"center"`, `"right"`, `"distribute"`
## Bordered containers
Use `border=True` on containers for visual grouping. See `building-streamlit-dashboards` for dashboard-specific patterns like KPI cards.
```python
with st.container(border=True):
st.subheader("Section title")
st.write("Grouped content here")
```
## Tabs
Organize content into switchable views:
```python
tab1, tab2 = st.tabs(["Chart", "Data"])
with tab1:
st.line_chart(data)
with tab2:
st.dataframe(df)
```
## Expander
Collapsible sections for secondary content:
```python
with st.expander("See details"):
st.write("Hidden content here")
st.code("print('hello')")
```
## Empty and placeholders
`st.empty()` creates a single-element placeholder that can be updated or cleared:
```python
placeholder = st.empty()
# Update the placeholder
placeholder.text("Loading...")
result = load_data()
placeholder.dataframe(result)
# Clear it
placeholder.empty()
```
## Popover
Click to reveal content:
```python
with st.popover("Settings"):
st.checkbox("Dark mode")
st.slider("Font size", 10, 24)
```
## Dialogs for focused interactions
Use `@st.dialog` for UI that doesn't need to be always visible:
```python
@st.dialog("Confirm deletion")
def confirm_delete(item_name):
st.write(f"Are you sure you want to delete **{item_name}**?")
if st.button("Delete", type="primary"):
delete_item(item_name)
st.rerun()
if st.button("Delete item"):
confirm_delete("My Document")
```
**Key points:**
- Dialogs rerun independently from the main script
- Use `st.session_state` to pass widget values from the dialog to the main app
- Call `st.rerun()` to close dialog and refresh main app
- Use `dismissible=False` for forced actions
- `st.sidebar` is not supported inside dialogs
**When to use dialogs:**
- Confirmation prompts
- Settings panels
- Forms that don't need to be always visible
## Spacing
Control spacing between elements with `gap` on containers:
```python
# Remove spacing for tight list-like UIs
with st.container(gap=None, border=True):
for item in items:
st.checkbox(item.text)
# Explicit gap sizes
with st.container(gap="small"):
...
```
Add vertical space with `st.space`:
```python
st.space("small") # Small gap
st.space("medium") # Medium gap
st.space("large") # Large gap
st.space(50) # Custom pixels
```
## Width and height
Control element sizing:
```python
# Stretch to fill available space (equal height columns)
cols = st.columns(2)
with cols[0].container(border=True, height="stretch"):
st.line_chart(data)
with cols[1].container(border=True, height="stretch"):
st.dataframe(df)
# Shrink to content size
st.container(width="content")
# Fixed pixel sizes
st.container(height=300)
```
## References
- [st.columns](https://docs.streamlit.io/develop/api-reference/layout/st.columns)
- [st.container](https://docs.streamlit.io/develop/api-reference/layout/st.container)
- [st.sidebar](https://docs.streamlit.io/develop/api-reference/layout/st.sidebar)
- [st.tabs](https://docs.streamlit.io/develop/api-reference/layout/st.tabs)
- [st.expander](https://docs.streamlit.io/develop/api-reference/layout/st.expander)
- [st.popover](https://docs.streamlit.io/develop/api-reference/layout/st.popover)
- [st.empty](https://docs.streamlit.io/develop/api-reference/layout/st.empty)
- [st.dialog](https://docs.streamlit.io/develop/api-reference/execution-flow/st.dialog)
@@ -0,0 +1,207 @@
---
name: using-streamlit-markdown
description: Covers all Markdown features in Streamlit including GitHub-flavored syntax plus Streamlit extensions like colored text, badges, Material icons, and LaTeX. Use when formatting text, labels, tooltips, or any text-rendering element.
license: Apache-2.0
---
# Using Markdown in Streamlit
Streamlit supports Markdown throughout its API—in `st.markdown()`, widget labels, help tooltips, metrics, `st.table()` cells, and more. Beyond standard GitHub-flavored Markdown, Streamlit adds colored text, badges, icons, and LaTeX.
## Quick reference
| Feature | Syntax | Example | Works in labels |
|---------|--------|---------|--------|
| Bold | `**text**` | `**Bold**` | ✓ |
| Italic | `*text*` | `*Italic*` | ✓ |
| Strikethrough | `~text~` | `~Strikethrough~` | ✓ |
| Inline code | `` `code` `` | `` `variable` `` | ✓ |
| Code block | ` ```lang...``` ` | ` ```python...``` ` | ✗ |
| Link | `[text](url)` | `[Streamlit](https://streamlit.io)` | ✓ |
| Image | `![alt](path)` | `![Logo](logo.png)` | ✓ |
| Heading | `# ` to `###### ` | `## Section` | ✗ |
| Blockquote | `> text` | `> Note` | ✗ |
| Horizontal rule | `---` | `---` | ✗ |
| Unordered list | `- item` | `- First`<br>`- Second` | ✗ |
| Ordered list | `1. item` | `1. First`<br>`2. Second` | ✗ |
| Task list | `- [ ]` / `- [x]` | `- [x] Done`<br>`- [ ] Todo` | ✗ |
| Table | `\| a \| b \|` | `\| H1 \| H2 \|`<br>`\|--\|--\|` | ✗ |
| Emoji | Direct or shortcode | `🎉` or `:tada:` | ✓ |
| Streamlit logo | `:streamlit:` | `:streamlit:` | ✓ |
| Material icon | `:material/icon_name:` | `:material/check_circle:` | ✓ |
| Colored text | `:color[text]` | `:red[Error]` | ✓ |
| Colored background | `:color-background[text]` | `:blue-background[Info]` | ✓ |
| Badge | `:color-badge[text]` | `:green-badge[Success]` | ✓ |
| Small text | `:small[text]` | `:small[footnote]` | ✓ |
| LaTeX (inline) | `$formula$` | `$ax^2 + bx + c$` | ✓ |
| LaTeX (block) | `$$formula$$` | `$$\int_0^1 x^2 dx$$` | ✗ |
## Where Markdown works
Markdown is supported in most places where text is rendered. Streamlit has three levels of markdown support:
**Full Markdown** — All syntax shown in the table above:
- `st.markdown()`, `st.write()`, `st.caption()`, `st.info()`, `st.warning()`, `st.error()`, `st.success()`, `st.table` cells and headers, tooltips (`help` parameter)
**Label subset** — Inline formatting only (see table above). Block elements (e.g. headings, lists, tables) are silently stripped:
- Widget and element labels (`st.button`, `st.checkbox`, `st.radio`, `st.expander`, `st.page_link`, etc.), `st.radio` and `st.select_slider` options, `st.tabs` names, `st.metric` label/value/delta, `st.title`, `st.header`, `st.subheader`, `st.image` caption, `st.dialog` title, `st.progress`, `st.spinner`.
**No Markdown** — Text displays literally:
- `st.text()`, `st.json()`, `st.dataframe()` / `st.data_editor()` cells, `st.selectbox` / `st.multiselect` options, input placeholders, `st.Page` titles, chart/map labels
## GitHub-flavored Markdown
Standard GFM syntax works as expected. Headings automatically get anchor links for navigation.
~~~python
st.markdown("""
# Heading
**Bold**, *italic*, ~~strikethrough~~, `inline code`, [links](url)
- Unordered list
- [x] Task list
| Column | Column |
|--------|--------|
| Cell | Cell |
> Blockquote
```python
code_block = "with syntax highlighting"
```
""")
~~~
## Colored text, backgrounds, and badges
```python
st.markdown(":red[Error] and :green[Success]") # Colored text
st.markdown(":blue-background[Highlighted]") # Colored background
st.markdown(":green-badge[Active] :red-badge[Inactive]") # Inline badges
```
**Available colors:** `red`, `orange`, `yellow`, `green`, `blue`, `violet`, `gray`/`grey`, `rainbow`, `primary`
Note: `rainbow` is not supported for backgrounds or badges. Standalone badges also available via `st.badge()`.
## Material icons
Use Google Material Symbols with `:material/icon_name:` syntax. Find icons at [fonts.google.com/icons](https://fonts.google.com/icons)
```python
st.markdown(":material/check_circle: Complete")
```
Material icons also work in `icon` parameters across many elements (`st.button`, `st.expander`, `st.info`, etc.).
## Emojis
Both Unicode emojis (preferred) and shortcodes work.
```python
st.markdown("Hello! 👋 :+1: :tada: :streamlit:")
```
**Note:** Material icons are preferred over emojis for a more professional look.
## LaTeX math
Single `$` for inline, double `$$` for display mode. Inline math requires non-whitespace after `$` to avoid conflicts with currency (e.g., "$5" won't be parsed as math).
```python
# Inline math
st.markdown("The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$")
# Display math (centered, larger)
st.markdown("""
$$
\\sum_{i=1}^{n} x_i = x_1 + x_2 + ... + x_n
$$
""")
```
## Images in Markdown
```python
st.markdown("![Alt text](https://example.com/image.png)")
st.button("![Logo](app/static/logo.png) Click me") # Image as icon in label
```
In labels, images display as icons with max height equal to font height.
## Markdown in element labels
Widgets, containers, and other elements support Markdown in their labels (using the label subset).
```python
st.radio(":material/palette: Choose **color**", [":red-background[Red]", ":blue-background[Blue]", ":green-background[Green]"])
tab1, tab2 = st.tabs([":material/home: Home", ":material/settings: Settings"])
st.metric(label=":material/attach_money: Revenue", value=":green[$1.2M]", delta=":material/trending_up: 12%")
```
## Escaping special characters
Use backslash to show literal characters: `\\[`, `\\*`, `1\\.`
```python
st.markdown(":blue[Array: \\[1, 2, 3\\]]")
st.button("1\\. Not a list")
```
## Markdown in st.table
`st.table()` renders Markdown in cells and headers.
```python
st.table({
"**Name**": "Alice",
"**Status**": ":green-badge[Active]",
"**Role**": ":material/shield: Admin"
})
```
## Combining features
Mix multiple features for rich formatting.
```python
st.markdown("""
### :material/rocket: Launch status
| Phase | Status | Notes |
|-------|--------|-------|
| Build | :green-badge[Complete] | All tests passing |
| Deploy | :orange-badge[In Progress] | ETA: 2 hours |
| Monitor | :gray-badge[Pending] | Waiting on deploy |
:small[Last updated: just now]
""")
```
## st.markdown - text alignment and width
Control layout with `text_alignment` and `width` parameters.
```python
st.markdown("Centered heading", text_alignment="center") # left, center, right, justify
st.markdown("Content width only", width="content") # stretch, content, or pixels (e.g. 400)
```
## HTML (use very sparingly!)
Mix Markdown with HTML using `unsafe_allow_html=True`. For pure HTML without markdown processing, use `st.html()` instead.
```python
st.markdown("**Status:** <span style='color: coral'>Custom styled</span>", unsafe_allow_html=True)
st.html("<div class='custom'>Pure HTML content</div>")
```
## References
- [st.markdown](https://docs.streamlit.io/develop/api-reference/text/st.markdown)
- [st.latex](https://docs.streamlit.io/develop/api-reference/text/st.latex)
- [GitHub-flavored Markdown spec](https://github.github.com/gfm)
- [Material Symbols](https://fonts.google.com/icons)
- [KaTeX supported functions](https://katex.org/docs/supported.html)
@@ -0,0 +1,144 @@
---
name: using-streamlit-session-state
description: Using st.session_state to manage state across Streamlit reruns. Use when persisting data, handling widget state, implementing callbacks, or debugging state issues. Covers initialization patterns, widget-state association, and common gotchas.
license: Apache-2.0
---
# Using Streamlit session state
Streamlit reruns scripts top-to-bottom on every interaction. Without session state, variables reset each time. Use `st.session_state` to persist values across reruns.
## Basic usage
Session state is a dictionary-like object supporting attribute and bracket notation:
```python
# Initialize with setdefault (preferred)
st.session_state.setdefault("count", 0)
# Alternative: check before setting
if "count" not in st.session_state:
st.session_state.count = 0
# Read
current = st.session_state.count
# Update
st.session_state.count += 1
st.session_state["count"] = 5 # Bracket notation also works
# Delete
del st.session_state.count
```
**Accessing uninitialized keys raises `KeyError`.** Use `st.session_state.get("key", default)` for safe access.
## Widget-state association
Every widget with a `key` parameter automatically syncs to session state:
```python
name = st.text_input("Name", key="user_name")
# st.session_state.user_name contains the same value as `name`
```
## Callbacks
Callbacks execute **before** the script reruns, allowing immediate state changes. Use `on_change` or `on_click` with optional `args` and `kwargs`:
```python
def increment(amount):
st.session_state.count += amount
st.button("Add 5", on_click=increment, args=(5,))
```
Access a widget's value in its own callback via `st.session_state.key`, not the return variable.
## Initialization patterns
Initialize all state at the top of your app for clarity:
```python
st.session_state.setdefault("user", None)
st.session_state.setdefault("page", "home")
st.session_state.setdefault("filters", {})
```
## Multipage state
Widgets are NOT stateful across pages. Their values reset when navigating between pages.
### Sharing state
Use session state variables (not widget keys) to share data:
```python
# Page 1: Store value
st.session_state.selected_user = st.selectbox("User", users)
# Page 2: Read stored value
if "selected_user" in st.session_state:
st.write(f"Selected: {st.session_state.selected_user}")
```
### Shared widgets pattern
Put common widgets in the entrypoint file (before `nav.run()`):
```python
# app.py (entrypoint)
with st.sidebar:
st.session_state.theme = st.selectbox("Theme", ["Light", "Dark"])
nav = st.navigation(pages)
nav.run()
```
## Common mistakes
### Module-level mutable state
```python
# BAD: In imported modules, this is shared across ALL users
# utils.py
cache = {} # Persists across reruns AND users!
# GOOD: Use session state for per-user data
st.session_state.setdefault("cache", {})
```
### Modifying state after widget creation
Cannot assign to a widget's state after the widget has rendered:
```python
st.slider("Value", key="my_slider")
st.session_state.my_slider = 50 # Raises StreamlitAPIException!
```
### Mixing `value` parameter and session state
Don't set both—it causes warnings:
```python
# BAD: Conflicting sources
st.session_state.setdefault("name", "Alice")
st.text_input("Name", value="Bob", key="name") # Warning!
# GOOD: Use one or the other
st.session_state.setdefault("name", "Alice")
st.text_input("Name", key="name")
```
## Session characteristics
- **Per-user, per-tab**: Each browser tab has its own session
- **Temporary**: Lost when tab closes or server restarts
- **Not suitable for persistence**: Use databases for permanent storage
## References
- [st.session_state API](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.session_state)
- [Session State concepts](https://docs.streamlit.io/develop/concepts/architecture/session-state)
- [Widget behavior](https://docs.streamlit.io/develop/concepts/architecture/widget-behavior)
@@ -0,0 +1,188 @@
# Streamlit Dashboard App Templates
This directory contains ready-to-use dashboard templates for Streamlit. Each template demonstrates best practices for building data-driven dashboards with modern UI patterns.
## Available Templates
### Public Demo Templates
These templates are based on official Streamlit demo apps and work out of the box:
| Template | Description | Key Features |
|----------|-------------|--------------|
| **dashboard-seattle-weather** | Weather data exploration dashboard | `st.metric`, `st.pills`, `st.altair_chart`, year comparison |
| **dashboard-stock-peers** | Stock peer analysis and comparison | `st.multiselect`, normalized charts, peer average calculation |
| **dashboard-stock-peers-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, synthetic stock data in SQL |
### Analytics Dashboard Templates
These templates demonstrate common dashboard patterns with synthetic data. Replace the data generation functions with your actual data sources:
| Template | Description | Key Features |
|----------|-------------|--------------|
| **dashboard-metrics** | Core metrics dashboard with KPIs | Chart/table toggle, `st.popover` filters, TIME_RANGES (1M/6M/1Y/QTD/YTD/All) |
| **dashboard-metrics-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, SQL-based data generation |
| **dashboard-feature-usage** | API endpoint usage analytics | Segmented control, starter kits, normalization toggle, rolling averages |
| **dashboard-companies** | Company leaderboard with drill-down | Interactive dataframe, sparkline columns, growth scores |
| **dashboard-compute** | Resource consumption monitoring | `@st.fragment`, `st.popover` filters, TIME_RANGES, line/bar toggle |
| **dashboard-compute-snowflake** | Same as above but using Snowflake | `st.connection("snowflake")`, SQL-based data generation |
## Quick Start
### Run a Template Locally
```bash
# Navigate to a template directory
cd templates/apps/dashboard-metrics
# Install dependencies with uv
uv pip install -e .
# Run the app
uv run streamlit run streamlit_app.py
```
## Template Structure
Each template follows this structure:
```
dashboard-{name}/
├── streamlit_app.py # Main application code
└── pyproject.toml # Dependencies and metadata
```
## Canonical Patterns
When creating new templates or adapting existing ones, follow these patterns for consistency.
### Page Configuration
Always set page config as the first Streamlit call, with `layout="wide"` and a Material icon:
```python
st.set_page_config(
page_title="My Dashboard",
page_icon=":material/monitoring:",
layout="wide",
)
```
### Constants
Use these standard constant names:
```python
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
CHART_HEIGHT = 300 # Standard chart height in pixels
```
### Time Range Filtering
All dashboard templates that support time filtering use the same `filter_by_time_range` function:
```python
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
"""Filter dataframe by time range."""
if time_range == "All" or df.empty:
return df
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
max_date = df[x_col].max()
if time_range == "1M":
min_date = max_date - timedelta(days=30)
elif time_range == "6M":
min_date = max_date - timedelta(days=180)
elif time_range == "1Y":
min_date = max_date - timedelta(days=365)
elif time_range == "QTD":
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
elif time_range == "YTD":
min_date = pd.Timestamp(date(max_date.year, 1, 1))
else:
return df
return df[df[x_col] >= min_date]
```
### Popover Filters
Compact filter controls using `st.popover`:
```python
with st.popover("Filters", type="tertiary"):
line_options = st.pills("Lines", ["Daily", "7-day MA"], selection_mode="multi")
time_range = st.segmented_control("Time range", TIME_RANGES, default="All")
```
### Page Header with Reset Button
```python
def render_page_header(title: str):
"""Render page header with title and reset button."""
with st.container(
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
):
st.markdown(title)
if st.button(":material/restart_alt: Reset", type="tertiary"):
st.session_state.clear()
st.rerun()
```
### Independent Widget Updates with @st.fragment
```python
@st.fragment
def metric_card():
with st.container(border=True):
# This widget updates independently without full page rerun
...
```
### Snowflake Column Normalization
Snowflake returns uppercase column names. Always normalize after queries:
```python
df = conn.query(query)
df.columns = df.columns.str.lower()
```
### Snowflake Connection Error Handling
```python
try:
get_snowflake_connection()
except Exception as e:
st.error(f"Failed to connect to Snowflake: {e}")
st.info(
"Make sure you have configured your Snowflake connection in "
"`.streamlit/secrets.toml` or via environment variables."
)
st.stop()
```
### Data Loading with Caching
```python
@st.cache_data(ttl=3600)
def load_metric_data() -> pd.DataFrame:
"""Load metric data. Replace with your actual data source."""
# Replace this with:
# - Snowflake query via st.connection("snowflake")
# - API call
# - Database query
return generate_synthetic_data()
```
## Dependencies
All templates require Python >=3.11 and use:
- `snowflake-connector-python>=3.3.0` (required — `streamlit[snowflake]` silently skips this on Python 3.12+)
- `streamlit[snowflake]>=1.54.0`
- `altair>=5.5.0`
- `pandas>=2.2.3`
- `numpy>=1.26.0`
@@ -0,0 +1,12 @@
[project]
name = "dashboard-companies"
version = "1.0.0"
description = "A company analytics dashboard with leaderboard, filtering, and drill-down dialogs"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"numpy>=1.26.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,365 @@
"""
Company Analytics Dashboard Template
A company leaderboard dashboard demonstrating:
- Interactive dataframe with sparkline columns
- Segmented control for ranking (top spenders, gainers, shrinkers)
- Multi-select pills for account type filtering
- Time window filtering
- Growth score calculation
- Dialog popup for company details
This template uses synthetic data. Replace generate_company_data()
with your actual data source (e.g., Snowflake queries, CRM APIs, etc.)
"""
from datetime import date, timedelta
import numpy as np
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="Company Analytics",
page_icon=":material/business:",
layout="wide",
)
# =============================================================================
# Synthetic Data Generation (Replace with your data source)
# =============================================================================
COMPANY_NAMES = [
"Acme Corp", "TechFlow Inc", "DataDriven Co", "CloudFirst Ltd",
"InnovateTech", "ScaleUp Systems", "PrimeData Inc", "FutureStack",
"ByteWise Corp", "StreamLine Co", "Quantum Labs", "NexGen Solutions",
"AlphaMetrics", "BetaAnalytics", "GammaInsights", "DeltaData",
"OmegaTech", "SigmaSoft", "ThetaCloud", "ZetaDigital",
]
ACCOUNT_TYPES = ["Enterprise", "Growth", "Startup", "Trial", "Internal"]
REGIONS = ["North America", "EMEA", "APAC", "LATAM"]
SEGMENTS = ["Technology", "Finance", "Healthcare", "Retail", "Manufacturing"]
@st.cache_data(ttl=3600)
def generate_company_data(days: int = 90) -> pd.DataFrame:
"""Generate synthetic company usage data.
Replace this function with your actual data source.
"""
np.random.seed(42)
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=days)
dates = pd.date_range(start=start_date, end=end_date, freq="D")
records = []
for company in COMPANY_NAMES:
# Assign static attributes
account_type = np.random.choice(ACCOUNT_TYPES, p=[0.3, 0.25, 0.2, 0.15, 0.1])
region = np.random.choice(REGIONS)
segment = np.random.choice(SEGMENTS)
# Generate usage pattern
base_usage = np.random.randint(100, 10000)
growth = np.random.uniform(-0.005, 0.01) # Some companies shrink
for i, dt in enumerate(dates):
# Base trend
trend = base_usage * (1 + growth) ** i
# Weekly seasonality
if dt.dayofweek >= 5:
trend *= 0.3
# Random noise
daily_credits = max(0, trend * np.random.uniform(0.7, 1.3))
records.append({
"company_name": company,
"date": dt,
"daily_credits": daily_credits,
"account_type": account_type,
"region": region,
"segment": segment,
})
return pd.DataFrame(records)
@st.cache_data(ttl=3600)
def load_company_data() -> pd.DataFrame:
"""Load all company data."""
return generate_company_data(days=90)
def aggregate_companies(
df: pd.DataFrame,
days: int | None = None,
account_types: list[str] | None = None,
sort_by: str = "total_credits",
) -> pd.DataFrame:
"""Filter and aggregate company data."""
result = df.copy()
# Filter by time window
if days:
cutoff = pd.Timestamp.now() - pd.Timedelta(days=days)
result = result[result["date"] >= cutoff]
# Filter by account type
if account_types:
result = result[result["account_type"].isin(account_types)]
if result.empty:
return pd.DataFrame()
# Aggregate to company level
agg = result.groupby("company_name").agg(
total_credits=("daily_credits", "sum"),
active_days=("date", "nunique"),
account_type=("account_type", "first"),
region=("region", "first"),
segment=("segment", "first"),
).reset_index()
# Calculate daily average
agg["daily_avg"] = agg["total_credits"] / agg["active_days"]
# Build sparkline data (list of daily values)
sparklines = (
result.groupby("company_name")
.apply(lambda x: x.sort_values("date")["daily_credits"].tolist())
.reset_index()
)
sparklines.columns = ["company_name", "usage_trend"]
agg = agg.merge(sparklines, on="company_name")
# Calculate growth score (second half vs first half)
def calc_growth(trend):
if not trend or len(trend) < 2:
return 0
mid = len(trend) // 2
first_half = sum(trend[:mid]) if mid > 0 else 0
second_half = sum(trend[mid:])
return second_half - first_half
agg["growth_score"] = agg["usage_trend"].apply(calc_growth)
# Sort
if sort_by == "growth_asc":
agg = agg.sort_values("growth_score", ascending=True)
elif sort_by == "growth_desc":
agg = agg.sort_values("growth_score", ascending=False)
else:
agg = agg.sort_values("total_credits", ascending=False)
return agg
def render_company_dialog(company_name: str, company_row: pd.Series, df: pd.DataFrame):
"""Render company details inside a dialog."""
company_data = df[df["company_name"] == company_name].sort_values("date")
if company_data.empty:
st.warning("No data available for this company.")
return
# Company info badges - extract from list format back to single value
account_type = company_row["account_type"][0] if company_row["account_type"] else "Unknown"
region = company_row["region"][0] if company_row["region"] else "Unknown"
segment = company_row["segment"][0] if company_row["segment"] else "Unknown"
total_credits = company_row["total_credits"]
st.markdown(
f":blue-badge[{account_type}] "
f":violet-badge[{region}] "
f":orange-badge[{segment}] "
f":green-badge[{total_credits:,.0f} credits]"
)
# Summary metrics
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Total Credits", f"{total_credits:,.0f}")
with col2:
st.metric("Active Days", f"{company_row['active_days']:,}")
with col3:
growth = company_row["growth_score"]
st.metric("Growth Score", f"{growth:+,.0f}")
# Charts
col1, col2 = st.columns(2)
with col1:
with st.container(border=True):
st.markdown("**Daily usage**")
st.line_chart(company_data, x="date", y="daily_credits", height=250)
with col2:
with st.container(border=True):
st.markdown("**Cumulative usage**")
chart_data = company_data.copy()
chart_data["cumulative"] = chart_data["daily_credits"].cumsum()
st.area_chart(chart_data, x="date", y="cumulative", height=250)
# =============================================================================
# Page Layout
# =============================================================================
# Load data
all_data = load_company_data()
st.markdown("# :material/business: Company Analytics")
st.caption("Track company adoption - usage, growth trends, and account details.")
# Filters
with st.container(border=True):
st.markdown("**Filters**")
# Company selection mode
sort_mode = st.segmented_control(
"Sort by",
options=[
"All companies",
":material/military_tech: Top spenders",
":material/trending_down: Top shrinkers",
":material/trending_up: Top gainers",
],
default="All companies",
)
# Time window
timeframe_options = {
"All time": None,
"Last 28 days": 28,
"Last 7 days": 7,
}
timeframe = st.segmented_control(
"Time window",
options=list(timeframe_options.keys()),
default="Last 28 days",
)
days_filter = timeframe_options.get(timeframe)
# Account types
account_types = st.pills(
"Account types",
options=ACCOUNT_TYPES,
default=["Enterprise", "Growth", "Startup"],
selection_mode="multi",
)
# Determine sort order
if "Top shrinkers" in (sort_mode or ""):
sort_by = "growth_asc"
elif "Top gainers" in (sort_mode or ""):
sort_by = "growth_desc"
else:
sort_by = "total_credits"
# Get filtered data
leaderboard = aggregate_companies(
all_data,
days=days_filter,
account_types=account_types,
sort_by=sort_by,
)
if leaderboard.empty:
st.warning("No company data found for the selected filters.")
st.stop()
def _to_list(val):
"""Convert a single value to a list for MultiselectColumn display."""
return [val] if pd.notna(val) else []
# Convert columns to lists for MultiselectColumn display (shows nice colored chips)
for col in ["account_type", "region", "segment"]:
leaderboard[col] = leaderboard[col].apply(_to_list)
# Companies dataframe
with st.container(border=True):
timeframe_text = timeframe.lower() if timeframe != "All time" else "all time"
st.markdown(f"**Companies — {timeframe_text}**")
# Selection dataframe with cell-click support
selection = st.dataframe(
leaderboard,
column_config={
"company_name": st.column_config.TextColumn(
"Company (👋 click to view details)",
width="medium",
),
"account_type": st.column_config.MultiselectColumn(
"Type",
options=ACCOUNT_TYPES,
color="auto",
width="small",
),
"total_credits": st.column_config.NumberColumn(
"Credits",
format="%.0f",
),
"growth_score": st.column_config.NumberColumn(
"Growth",
format="%+.0f",
help="Credit change: second half vs first half of period",
),
"usage_trend": st.column_config.LineChartColumn(
"Trend",
width="medium",
),
"daily_avg": st.column_config.NumberColumn(
"Daily Avg",
format="%.1f",
),
"active_days": st.column_config.NumberColumn(
"Active Days",
format="%d",
),
"region": st.column_config.MultiselectColumn(
"Region",
options=REGIONS,
color="auto",
),
"segment": st.column_config.MultiselectColumn(
"Segment",
options=SEGMENTS,
color="auto",
),
},
column_order=[
"company_name", "account_type", "total_credits", "growth_score",
"usage_trend", "daily_avg", "region", "segment",
],
hide_index=True,
on_select="rerun",
selection_mode="single-cell",
key="company_leaderboard",
)
# Company drill-down via dialog when Company column cell is clicked
if selection.selection.cells:
cell = selection.selection.cells[0] # tuple: (row_index, column_name)
row_idx, col_name = cell
# Check if the clicked cell is in the company_name column
if col_name == "company_name":
selected_company = leaderboard.iloc[row_idx]["company_name"]
company_row = leaderboard.iloc[row_idx]
@st.dialog(f"{selected_company}", width="large")
def show_company_dialog():
render_company_dialog(
selected_company,
company_row=company_row,
df=all_data,
)
show_company_dialog()
@@ -0,0 +1,11 @@
[project]
name = "dashboard-compute-snowflake"
version = "1.0.0"
description = "Compute dashboard template with Snowflake connection"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,18 @@
definition_version: 2
entities:
DASHBOARD_COMPUTE_SNOWFLAKE:
type: streamlit
identifier:
name: DASHBOARD_COMPUTE_SNOWFLAKE
database: <FROM_CONNECTION> # Use: snow connection list
schema: <FROM_CONNECTION>
query_warehouse: <FROM_CONNECTION>
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
external_access_integrations:
- <YOUR_PYPI_INTEGRATION>
main_file: streamlit_app.py
artifacts:
- streamlit_app.py
- pyproject.toml
@@ -0,0 +1,527 @@
"""
Compute/Resource Dashboard Template (Snowflake Edition)
A resource consumption dashboard demonstrating:
- Snowflake connection via st.connection("snowflake")
- Parameterized queries for safe data loading
- Multiple metric cards in a grid layout
- @st.fragment for independent widget updates
- Popover filters for each metric card
- Chart/table view toggle
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
This template uses synthetic data generated in Snowflake. Replace the
synthetic queries with your actual table queries in production.
"""
from datetime import date, timedelta
import re
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="Compute Dashboard (Snowflake)",
page_icon=":material/bolt:",
layout="wide",
)
# =============================================================================
# Constants
# =============================================================================
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
ACCOUNT_TYPES = ["Paying", "Trial", "Internal"]
INSTANCE_TYPES = ["Standard", "High Memory", "High CPU", "GPU"]
REGIONS = ["us-west-2", "us-east-1", "eu-west-1", "ap-northeast-1"]
CHART_HEIGHT = 350
# Base values for synthetic data generation
BASE_VALUES = {
"account_type": {"Paying": 8000, "Trial": 2000, "Internal": 1000},
"instance_type": {"Standard": 5000, "High Memory": 3000, "High CPU": 2000, "GPU": 1500},
"region": {"us-west-2": 4000, "us-east-1": 3500, "eu-west-1": 2500, "ap-northeast-1": 1500},
}
# =============================================================================
# Snowflake Connection and Data Loading
# =============================================================================
def get_snowflake_connection():
"""Get Snowflake connection via st.connection.
Displays an error and stops the app if the connection fails.
"""
try:
return st.connection("snowflake")
except Exception as e:
st.error(f"Failed to connect to Snowflake: {e}")
st.info(
"Make sure you have configured your Snowflake connection in "
"`.streamlit/secrets.toml` or via environment variables."
)
st.stop()
# =============================================================================
# IMPORTANT: Use parameterized queries in production
# =============================================================================
#
# This demo uses synthetic data generated via SQL. In production, always use
# parameterized queries to prevent SQL injection:
#
# # GOOD: Parameterized query (safe)
# conn = st.connection("snowflake")
# df = conn.query(
# "SELECT * FROM metrics WHERE category = :category AND ds >= :start_date",
# params={"category": selected_category, "start_date": start_date}
# )
#
# # BAD: f-string interpolation (SQL injection risk)
# df = conn.query(f"SELECT * FROM metrics WHERE category = '{user_input}'")
#
# The synthetic data generation below uses f-strings only because the values
# are hardcoded constants, not user input. Never use f-strings with user input.
def _validate_sql_identifier(name: str) -> str:
"""Validate that a string is a safe SQL identifier (letters, digits, underscores).
Raises ValueError if the name contains unexpected characters. This prevents
SQL injection if the function is ever modified to accept dynamic input.
"""
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise ValueError(f"Invalid SQL identifier: {name!r}")
return name
def build_synthetic_query(category_col: str, categories: list[str], base_values: dict[str, int]) -> str:
"""Build SQL query for synthetic data.
WARNING: This function uses f-strings for demo purposes only.
The categories are hardcoded constants defined in this file, not user input.
In production, always use parameterized queries with conn.query(..., params={}).
"""
# Validate the column name used as a SQL identifier (appears unquoted in SQL)
_validate_sql_identifier(category_col)
# Category values appear as string literals in SQL VALUES clause.
# Escape single quotes to prevent SQL injection.
safe_categories = [cat.replace("'", "''") for cat in categories]
# Build VALUES clause for categories with their base values
values_rows = ", ".join(
f"('{cat}', {base_values.get(orig, 1000)})"
for cat, orig in zip(safe_categories, categories)
)
return f"""
WITH categories AS (
SELECT column1 AS category, column2 AS base_val
FROM VALUES {values_rows}
),
date_series AS (
SELECT DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS ds
FROM TABLE(GENERATOR(ROWCOUNT => 730))
),
base_data AS (
SELECT
ds,
category,
base_val * POWER(1.002, DATEDIFF(day, DATEADD(year, -2, CURRENT_DATE()), ds)) AS base_trend,
CASE WHEN DAYOFWEEK(ds) IN (0, 6) THEN 0.4 ELSE 1.0 END AS seasonality,
1 + (RANDOM() / 10000000000000000000.0 - 0.5) * 0.4 AS noise
FROM date_series
CROSS JOIN categories
WHERE ds >= DATEADD(year, -2, CURRENT_DATE())
)
SELECT
ds,
category AS {category_col},
GREATEST(0, ROUND(base_trend * seasonality * noise, 2)) AS daily_credits,
ROUND(AVG(GREATEST(0, base_trend * seasonality * noise)) OVER (
PARTITION BY category
ORDER BY ds ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS credits_7d_ma
FROM base_data
ORDER BY ds, {category_col}
"""
@st.cache_data(ttl=3600, show_spinner="Loading account type data...")
def load_account_type_data() -> pd.DataFrame:
"""Load credits by account type from Snowflake."""
conn = get_snowflake_connection()
query = build_synthetic_query("account_type", ACCOUNT_TYPES, BASE_VALUES["account_type"])
df = conn.query(query)
df.columns = df.columns.str.lower()
return df
@st.cache_data(ttl=3600, show_spinner="Loading instance type data...")
def load_instance_type_data() -> pd.DataFrame:
"""Load credits by instance type from Snowflake."""
conn = get_snowflake_connection()
query = build_synthetic_query("instance_type", INSTANCE_TYPES, BASE_VALUES["instance_type"])
df = conn.query(query)
df.columns = df.columns.str.lower()
return df
@st.cache_data(ttl=3600, show_spinner="Loading region data...")
def load_region_data() -> pd.DataFrame:
"""Load credits by region from Snowflake."""
conn = get_snowflake_connection()
query = build_synthetic_query("region", REGIONS, BASE_VALUES["region"])
df = conn.query(query)
df.columns = df.columns.str.lower()
return df
# =============================================================================
# Chart Utilities
# =============================================================================
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
"""Filter dataframe by time range."""
if time_range == "All" or df.empty:
return df
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
max_date = df[x_col].max()
if time_range == "1M":
min_date = max_date - timedelta(days=30)
elif time_range == "6M":
min_date = max_date - timedelta(days=180)
elif time_range == "1Y":
min_date = max_date - timedelta(days=365)
elif time_range == "QTD":
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
elif time_range == "YTD":
min_date = pd.Timestamp(date(max_date.year, 1, 1))
else:
return df
return df[df[x_col] >= min_date]
def create_line_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
color_col: str,
height: int,
show_percent: bool = False,
) -> alt.Chart:
"""Create a line chart."""
y_format = ".1%" if show_percent else ",.0f"
return (
alt.Chart(df)
.mark_line()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y(f"{y_col}:Q", title="Credits", axis=alt.Axis(format=y_format)),
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip(f"{color_col}:N", title=color_col.replace("_", " ").title()),
alt.Tooltip(f"{y_col}:Q", title="Credits", format=y_format),
],
)
.properties(height=height)
.interactive()
)
def create_bar_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
color_col: str,
height: int,
show_percent: bool = False,
) -> alt.Chart:
"""Create a stacked bar chart."""
y_format = ".1%" if show_percent else ",.0f"
return (
alt.Chart(df)
.mark_bar()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y(
f"{y_col}:Q",
title="Credits",
stack="normalize" if show_percent else True,
axis=alt.Axis(format=y_format),
),
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip(f"{color_col}:N"),
alt.Tooltip(f"{y_col}:Q", format=",.0f"),
],
)
.properties(height=height)
)
# =============================================================================
# Page Header Component
# =============================================================================
def render_page_header(title: str):
"""Render page header with title and reset button."""
with st.container(
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
):
st.markdown(title)
if st.button(":material/restart_alt: Reset", type="tertiary"):
st.session_state.clear()
st.rerun()
# =============================================================================
# Metric Card Components (using @st.fragment)
# =============================================================================
@st.fragment
def account_type_metric():
"""Account type metric card with independent state."""
data = load_account_type_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by account type**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="acct_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_types = st.pills(
"Account types",
options=ACCOUNT_TYPES,
default=["Paying"],
selection_mode="multi",
key="acct_types",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="acct_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/show_chart: Line",
key="acct_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="acct_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="acct_time",
)
# Filter data
selected_types = selected_types or ["Paying"]
line_options = line_options or ["7-day MA"]
filtered = data[data["account_type"].isin(selected_types)]
filtered = filter_by_time_range(filtered, "ds", time_range)
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT),
)
@st.fragment
def instance_type_metric():
"""Instance type metric card with independent state."""
data = load_instance_type_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by instance type**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="inst_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_types = st.pills(
"Instance types",
options=INSTANCE_TYPES,
default=INSTANCE_TYPES,
selection_mode="multi",
key="inst_types",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="inst_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/show_chart: Line",
key="inst_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="inst_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="inst_time",
)
# Filter data
selected_types = selected_types or INSTANCE_TYPES
line_options = line_options or ["7-day MA"]
filtered = data[data["instance_type"].isin(selected_types)]
filtered = filter_by_time_range(filtered, "ds", time_range)
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT),
)
@st.fragment
def region_metric():
"""Region metric card with independent state."""
data = load_region_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by region**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="region_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_regions = st.pills(
"Regions",
options=REGIONS,
default=REGIONS,
selection_mode="multi",
key="region_select",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="region_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/bar_chart: Bar",
key="region_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="region_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="region_time",
)
# Filter data
selected_regions = selected_regions or REGIONS
line_options = line_options or ["7-day MA"]
filtered = data[data["region"].isin(selected_regions)]
filtered = filter_by_time_range(filtered, "ds", time_range)
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "region", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "region", CHART_HEIGHT),
)
# =============================================================================
# Page Layout
# =============================================================================
# Check Snowflake connection
get_snowflake_connection()
render_page_header("# :material/bolt: Compute Dashboard")
st.caption(":material/cloud: Powered by Snowflake")
# Row 1: Two metrics
col1, col2 = st.columns(2)
with col1:
account_type_metric()
with col2:
instance_type_metric()
# Row 2: One metric (full width for region breakdown)
region_metric()
@@ -0,0 +1,12 @@
[project]
name = "dashboard-compute"
version = "1.0.0"
description = "A compute/resource consumption dashboard with multiple metric breakdowns"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"numpy>=1.26.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,461 @@
"""
Compute/Resource Dashboard Template
A resource consumption dashboard demonstrating:
- Multiple metric cards in a grid layout
- @st.fragment for independent widget updates
- Popover filters for each metric card
- Chart/table view toggle
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
- Percentage normalization toggle
- Multiple breakdown dimensions
This template uses synthetic data. Replace generate_*_data()
with your actual data source (e.g., Snowflake queries, cloud APIs, etc.)
"""
from datetime import date, timedelta
import numpy as np
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="Compute Dashboard",
page_icon=":material/bolt:",
layout="wide",
)
# =============================================================================
# Constants
# =============================================================================
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
ACCOUNT_TYPES = ["Paying", "Trial", "Internal"]
INSTANCE_TYPES = ["Standard", "High Memory", "High CPU", "GPU"]
REGIONS = ["us-west-2", "us-east-1", "eu-west-1", "ap-northeast-1"]
CHART_HEIGHT = 350
# =============================================================================
# Synthetic Data Generation
# =============================================================================
def generate_time_series(
categories: list[str],
category_name: str,
start_date: date,
end_date: date,
base_values: dict[str, float] | None = None,
) -> pd.DataFrame:
"""Generate synthetic time series data by category."""
np.random.seed(hash(category_name) % 2**32)
dates = pd.date_range(start=start_date, end=end_date, freq="D")
records = []
for category in categories:
base = base_values.get(category, 1000) if base_values else np.random.randint(500, 5000)
growth = np.random.uniform(0.001, 0.005)
for i, dt in enumerate(dates):
trend = base * (1 + growth) ** i
if dt.dayofweek >= 5:
trend *= 0.4
daily = max(0, trend * np.random.uniform(0.8, 1.2))
records.append({
"ds": dt,
category_name: category,
"daily_credits": daily,
})
df = pd.DataFrame(records)
# Add 7-day moving average
df["credits_7d_ma"] = (
df.groupby(category_name)["daily_credits"]
.transform(lambda x: x.rolling(7, min_periods=1).mean())
)
return df
@st.cache_data(ttl=3600)
def load_account_type_data() -> pd.DataFrame:
"""Load credits by account type."""
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=730) # 2 years
return generate_time_series(
ACCOUNT_TYPES, "account_type", start_date, end_date,
base_values={"Paying": 8000, "Trial": 2000, "Internal": 1000},
)
@st.cache_data(ttl=3600)
def load_instance_type_data() -> pd.DataFrame:
"""Load credits by instance type."""
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=730)
return generate_time_series(
INSTANCE_TYPES, "instance_type", start_date, end_date,
base_values={"Standard": 5000, "High Memory": 3000, "High CPU": 2000, "GPU": 1500},
)
@st.cache_data(ttl=3600)
def load_region_data() -> pd.DataFrame:
"""Load credits by region."""
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=730)
return generate_time_series(
REGIONS, "region", start_date, end_date,
base_values={"us-west-2": 4000, "us-east-1": 3500, "eu-west-1": 2500, "ap-northeast-1": 1500},
)
# =============================================================================
# Chart Utilities
# =============================================================================
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
"""Filter dataframe by time range."""
if time_range == "All" or df.empty:
return df
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
max_date = df[x_col].max()
if time_range == "1M":
min_date = max_date - timedelta(days=30)
elif time_range == "6M":
min_date = max_date - timedelta(days=180)
elif time_range == "1Y":
min_date = max_date - timedelta(days=365)
elif time_range == "QTD":
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
elif time_range == "YTD":
min_date = pd.Timestamp(date(max_date.year, 1, 1))
else:
return df
return df[df[x_col] >= min_date]
def create_line_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
color_col: str,
height: int,
show_percent: bool = False,
) -> alt.Chart:
"""Create a line chart."""
y_format = ".1%" if show_percent else ",.0f"
return (
alt.Chart(df)
.mark_line()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y(f"{y_col}:Q", title="Credits", axis=alt.Axis(format=y_format)),
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip(f"{color_col}:N", title=color_col.replace("_", " ").title()),
alt.Tooltip(f"{y_col}:Q", title="Credits", format=y_format),
],
)
.properties(height=height)
.interactive()
)
def create_bar_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
color_col: str,
height: int,
show_percent: bool = False,
) -> alt.Chart:
"""Create a stacked bar chart."""
y_format = ".1%" if show_percent else ",.0f"
return (
alt.Chart(df)
.mark_bar()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y(
f"{y_col}:Q",
title="Credits",
stack="normalize" if show_percent else True,
axis=alt.Axis(format=y_format),
),
color=alt.Color(f"{color_col}:N", legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip(f"{color_col}:N"),
alt.Tooltip(f"{y_col}:Q", format=",.0f"),
],
)
.properties(height=height)
)
# =============================================================================
# Page Header Component
# =============================================================================
def render_page_header(title: str):
"""Render page header with title and reset button."""
with st.container(
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
):
st.markdown(title)
if st.button(":material/restart_alt: Reset", type="tertiary"):
st.session_state.clear()
st.rerun()
# =============================================================================
# Metric Card Components (using @st.fragment)
# =============================================================================
@st.fragment
def account_type_metric():
"""Account type metric card with independent state."""
data = load_account_type_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by account type**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="acct_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_types = st.pills(
"Account types",
options=ACCOUNT_TYPES,
default=["Paying"],
selection_mode="multi",
key="acct_types",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="acct_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/show_chart: Line",
key="acct_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="acct_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="acct_time",
)
# Filter data
selected_types = selected_types or ["Paying"]
line_options = line_options or ["7-day MA"]
filtered = data[data["account_type"].isin(selected_types)]
filtered = filter_by_time_range(filtered, "ds", time_range)
# Determine y column
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "account_type", CHART_HEIGHT),
)
@st.fragment
def instance_type_metric():
"""Instance type metric card with independent state."""
data = load_instance_type_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by instance type**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="inst_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_types = st.pills(
"Instance types",
options=INSTANCE_TYPES,
default=INSTANCE_TYPES,
selection_mode="multi",
key="inst_types",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="inst_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/show_chart: Line",
key="inst_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="inst_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="inst_time",
)
# Filter data
selected_types = selected_types or INSTANCE_TYPES
line_options = line_options or ["7-day MA"]
filtered = data[data["instance_type"].isin(selected_types)]
filtered = filter_by_time_range(filtered, "ds", time_range)
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "instance_type", CHART_HEIGHT),
)
@st.fragment
def region_metric():
"""Region metric card with independent state."""
data = load_region_data()
with st.container(border=True):
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
st.markdown("**Credits by region**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key="region_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
selected_regions = st.pills(
"Regions",
options=REGIONS,
default=REGIONS,
selection_mode="multi",
key="region_select",
)
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["7-day MA"],
selection_mode="multi",
key="region_lines",
)
chart_type = st.segmented_control(
"Chart type",
options=[":material/show_chart: Line", ":material/bar_chart: Bar"],
default=":material/bar_chart: Bar",
key="region_chart",
)
show_percent = st.toggle(
"Show %", value=False, key="region_pct",
disabled="Line" in (chart_type or ""),
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key="region_time",
)
# Filter data
selected_regions = selected_regions or REGIONS
line_options = line_options or ["7-day MA"]
filtered = data[data["region"].isin(selected_regions)]
filtered = filter_by_time_range(filtered, "ds", time_range)
y_col = "credits_7d_ma" if "7-day MA" in line_options else "daily_credits"
if "table" in (view_mode or ""):
st.dataframe(filtered, height=CHART_HEIGHT, hide_index=True)
else:
if "Bar" in (chart_type or ""):
st.altair_chart(
create_bar_chart(filtered, "ds", y_col, "region", CHART_HEIGHT, show_percent),
)
else:
st.altair_chart(
create_line_chart(filtered, "ds", y_col, "region", CHART_HEIGHT),
)
# =============================================================================
# Page Layout
# =============================================================================
render_page_header("# :material/bolt: Compute Dashboard")
# Row 1: Two metrics
col1, col2 = st.columns(2)
with col1:
account_type_metric()
with col2:
instance_type_metric()
# Row 2: One metric (full width for region breakdown)
region_metric()
@@ -0,0 +1,12 @@
[project]
name = "dashboard-feature-usage"
version = "1.0.0"
description = "A feature usage analytics dashboard with filtering and starter kits"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"numpy>=1.26.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,307 @@
"""
API Usage Dashboard Template
A feature analytics dashboard demonstrating:
- Segmented control for category selection
- Multiselect for endpoint filtering
- Starter kits / presets for quick selection
- Time series visualization with normalization
- Metric cards with 28-day deltas
- Rolling average options
This template uses synthetic data. Replace generate_api_data()
with your actual data source (e.g., Snowflake queries, APIs, etc.)
"""
from datetime import date, timedelta
import numpy as np
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="API Usage Dashboard",
page_icon=":material/api:",
layout="wide",
)
# =============================================================================
# Synthetic Data Generation (Replace with your data source)
# =============================================================================
# API categories and their endpoints
API_CATEGORIES = {
"Users": ["/users", "/users/{id}", "/users/me", "/users/search", "/users/bulk", "/users/export"],
"Orders": ["/orders", "/orders/{id}", "/orders/create", "/orders/cancel", "/orders/refund", "/orders/status"],
"Products": ["/products", "/products/{id}", "/products/search", "/products/categories", "/products/inventory"],
"Analytics": ["/analytics/events", "/analytics/metrics", "/analytics/reports", "/analytics/dashboards"],
}
# Starter kits - predefined endpoint selections
STARTER_KITS = {
"None": [],
"Core CRUD": ["/users", "/users/{id}", "/orders", "/orders/{id}"],
"Search": ["/users/search", "/products/search", "/products/categories"],
"Analytics": ["/analytics/events", "/analytics/metrics", "/analytics/reports"],
"High Volume": ["/users", "/products", "/orders", "/analytics/events"],
}
ROLLING_OPTIONS = {"Raw": 1, "7-day average": 7, "28-day average": 28}
def generate_api_data(
endpoints: list[str],
start_date: date,
end_date: date,
) -> pd.DataFrame:
"""Generate synthetic API usage data.
Replace this function with your actual data source.
"""
np.random.seed(42)
dates = pd.date_range(start=start_date, end=end_date, freq="D")
records = []
for endpoint in endpoints:
# Each endpoint has different base traffic and growth
base = np.random.randint(1000, 50000)
growth = np.random.uniform(0.0005, 0.003)
for i, dt in enumerate(dates):
# Base trend with growth
trend = base * (1 + growth) ** i
# Weekly seasonality (lower on weekends)
if dt.dayofweek >= 5:
trend *= 0.4
# Random noise
value = trend * np.random.uniform(0.85, 1.15)
records.append({
"date": dt,
"endpoint": endpoint,
"request_count": int(value),
})
df = pd.DataFrame(records)
return df
@st.cache_data(ttl=3600)
def load_api_data() -> pd.DataFrame:
"""Load all API usage data."""
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=365)
all_endpoints = []
for endpoints in API_CATEGORIES.values():
all_endpoints.extend(endpoints)
return generate_api_data(all_endpoints, start_date, end_date)
def apply_rolling_average(df: pd.DataFrame, window: int) -> pd.DataFrame:
"""Apply rolling average to request data."""
if window == 1:
return df
result = df.copy()
result["request_count"] = (
result.groupby("endpoint")["request_count"]
.transform(lambda x: x.rolling(window, min_periods=1).mean())
)
return result
def normalize_data(df: pd.DataFrame) -> pd.DataFrame:
"""Normalize request counts to percentages (share of total per day)."""
result = df.copy()
daily_totals = result.groupby("date")["request_count"].transform("sum")
result["request_count"] = result["request_count"] / daily_totals
return result
def calculate_delta(df: pd.DataFrame, endpoint: str) -> tuple[float, float | None]:
"""Calculate 28-day delta for an endpoint."""
endpoint_data = df[df["endpoint"] == endpoint].sort_values("date")
if len(endpoint_data) < 2:
return endpoint_data["request_count"].iloc[-1], None
latest = endpoint_data["request_count"].iloc[-1]
if len(endpoint_data) > 28:
previous = endpoint_data["request_count"].iloc[-29]
else:
previous = endpoint_data["request_count"].iloc[0]
delta = latest - previous
return latest, delta
# =============================================================================
# Page Layout
# =============================================================================
# Load data
raw_data = load_api_data()
# Header
st.markdown("# API Usage :material/api:")
st.caption("Select an API category to explore endpoint usage over time.")
# Category selection (not centered)
category = st.segmented_control(
"Select category",
options=[
":material/person: Users",
":material/shopping_cart: Orders",
":material/inventory_2: Products",
":material/analytics: Analytics",
],
default=":material/person: Users",
label_visibility="collapsed",
)
if not category:
st.warning("Please select a category above.", icon=":material/warning:")
st.stop()
# Map display name to category key
category_map = {
":material/person: Users": "Users",
":material/shopping_cart: Orders": "Orders",
":material/inventory_2: Products": "Products",
":material/analytics: Analytics": "Analytics",
}
selected_category = category_map[category]
st.subheader(f"{category} endpoints", divider="gray")
# Layout: filters on left, chart on right
filter_col, chart_col = st.columns([1, 2])
with filter_col:
# Metric selection
with st.expander("Metric", expanded=True, icon=":material/analytics:"):
measure = st.selectbox(
"Choose a measure",
["Request count", "Unique callers", "Error rate"],
index=0,
label_visibility="collapsed",
disabled=True, # Only one option in this template
help="In production, connect to different metrics tables",
)
rolling_label = st.segmented_control(
"Time aggregation",
list(ROLLING_OPTIONS.keys()),
default="7-day average",
label_visibility="collapsed",
)
if rolling_label is None:
st.caption("Please select a time aggregation.")
st.stop()
rolling_window = ROLLING_OPTIONS[rolling_label]
normalize = st.toggle(
"Normalize",
value=False,
help="Normalize to show percentage share of total requests",
)
# Starter kits
with st.expander("Starter kits", expanded=True, icon=":material/auto_awesome:"):
starter_kit = st.pills(
"Quick select",
options=list(STARTER_KITS.keys()),
default="None",
label_visibility="collapsed",
)
# Endpoint selection
available_endpoints = API_CATEGORIES[selected_category]
# Determine default selection based on starter kit
if starter_kit and starter_kit != "None":
default_endpoints = [e for e in STARTER_KITS[starter_kit] if e in available_endpoints]
else:
default_endpoints = available_endpoints[:4] # First 4 endpoints
with st.expander("Endpoints", expanded=True, icon=":material/checklist:"):
selected_endpoints = st.multiselect(
"Select endpoints",
options=available_endpoints,
default=default_endpoints,
label_visibility="collapsed",
)
# Filter and process data
if not selected_endpoints:
with chart_col:
st.info("Select at least one endpoint to view usage data.", icon=":material/info:")
st.stop()
filtered_data = raw_data[raw_data["endpoint"].isin(selected_endpoints)].copy()
filtered_data = apply_rolling_average(filtered_data, rolling_window)
if normalize:
filtered_data = normalize_data(filtered_data)
with chart_col:
# Latest metrics
with st.expander("Latest numbers", expanded=True, icon=":material/numbers:"):
metrics_row = st.container(horizontal=True)
for endpoint in selected_endpoints:
latest, delta = calculate_delta(filtered_data, endpoint)
if normalize:
value_str = f"{latest:.2%}"
delta_str = f"{delta:+.2%}" if delta is not None else None
else:
value_str = f"{latest:,.0f}"
delta_str = f"{delta:+,.0f}" if delta is not None else None
metrics_row.metric(
label=endpoint,
value=value_str,
delta=delta_str,
border=True,
)
# Time series chart
with st.expander("Time series", expanded=True, icon=":material/show_chart:"):
y_format = ".1%" if normalize else ",.0f"
y_title = "Share of requests" if normalize else "Request count"
chart = (
alt.Chart(filtered_data)
.mark_line()
.encode(
x=alt.X("date:T", title="Date"),
y=alt.Y("request_count:Q", title=y_title, axis=alt.Axis(format=y_format)),
color=alt.Color("endpoint:N", title="Endpoint", legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip("date:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("endpoint:N", title="Endpoint"),
alt.Tooltip("request_count:Q", title="Requests", format=y_format),
],
)
.properties(height=450)
.interactive()
)
st.altair_chart(chart)
# Raw data section
with st.expander("Raw data", expanded=False, icon=":material/table:"):
display_df = filtered_data.copy()
if normalize:
display_df["request_count"] = display_df["request_count"].apply(lambda x: f"{x:.2%}")
st.dataframe(display_df, hide_index=True)
@@ -0,0 +1,11 @@
[project]
name = "dashboard-metrics-snowflake"
version = "1.0.0"
description = "A metrics dashboard template using Snowflake for data storage and retrieval"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,18 @@
definition_version: 2
entities:
DASHBOARD_METRICS_SNOWFLAKE:
type: streamlit
identifier:
name: DASHBOARD_METRICS_SNOWFLAKE
database: <FROM_CONNECTION> # Use: snow connection list
schema: <FROM_CONNECTION>
query_warehouse: <FROM_CONNECTION>
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
external_access_integrations:
- <YOUR_PYPI_INTEGRATION>
main_file: streamlit_app.py
artifacts:
- streamlit_app.py
- pyproject.toml
@@ -0,0 +1,463 @@
"""
Metrics Dashboard Template (Snowflake Edition)
A comprehensive metrics dashboard demonstrating:
- Snowflake connection via st.connection("snowflake")
- Parameterized queries for safe data loading
- Time series visualization with Altair (line, area, bar, point charts)
- Metric cards with chart/table toggle and popover filters
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
- Line options (Daily, 7-day MA)
This template creates synthetic data in Snowflake. You can:
1. Replace the synthetic data generation with your actual tables
2. Modify the queries to match your schema (using parameterized queries)
"""
from datetime import date, timedelta
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="Metrics Dashboard (Snowflake)",
page_icon=":material/monitoring:",
layout="wide",
)
# =============================================================================
# Constants
# =============================================================================
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
CHART_HEIGHT = 300
# Metric configurations (used for synthetic data generation)
METRIC_CONFIGS = {
"users": {"base_value": 5000, "growth_rate": 0.002},
"sessions": {"base_value": 15000, "growth_rate": 0.003},
"revenue": {"base_value": 50000, "growth_rate": 0.001},
"conversions": {"base_value": 500, "growth_rate": 0.0015},
}
# =============================================================================
# Snowflake Connection and Data Loading
# =============================================================================
def get_snowflake_connection():
"""Get Snowflake connection via st.connection.
Displays an error and stops the app if the connection fails.
"""
try:
return st.connection("snowflake")
except Exception as e:
st.error(f"Failed to connect to Snowflake: {e}")
st.info(
"Make sure you have configured your Snowflake connection in "
"`.streamlit/secrets.toml` or via environment variables."
)
st.stop()
# SQL query template for synthetic data generation.
# Uses positional parameters (?) for Snowflake connector compatibility.
SYNTHETIC_DATA_QUERY = """
WITH date_series AS (
SELECT
DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS ds
FROM TABLE(GENERATOR(ROWCOUNT => 730))
),
base_data AS (
SELECT
ds,
? * POWER(1 + ?, DATEDIFF(day, '2023-01-01', ds)) AS base_trend,
CASE WHEN DAYOFWEEK(ds) IN (0, 6) THEN 0.7 ELSE 1.0 END AS seasonality,
1 + (RANDOM() / 10000000000000000000.0 - 0.5) * 0.2 AS noise
FROM date_series
WHERE ds >= '2023-01-01'
)
SELECT
ds,
ROUND(base_trend * seasonality * noise, 2) AS daily_value,
ROUND(AVG(base_trend * seasonality * noise) OVER (
ORDER BY ds ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS value_7d_ma
FROM base_data
ORDER BY ds
"""
@st.cache_data(ttl=3600, show_spinner="Loading metrics from Snowflake...")
def load_metric_from_snowflake(metric_name: str) -> pd.DataFrame:
"""Load metric data from Snowflake using parameterized queries.
In production, replace the synthetic query with your actual table query:
PRODUCTION_QUERY = '''
SELECT ds, daily_value, value_7d_ma
FROM your_schema.your_metrics_table
WHERE metric_name = ?
ORDER BY ds
'''
df = conn.query(PRODUCTION_QUERY, params=[metric_name])
"""
conn = get_snowflake_connection()
config = METRIC_CONFIGS[metric_name]
# Use parameterized query with positional parameters (list)
df = conn.query(
SYNTHETIC_DATA_QUERY,
params=[config["base_value"], config["growth_rate"]],
)
df.columns = df.columns.str.lower() # Normalize column names
return df
@st.cache_data(ttl=3600)
def load_all_metrics() -> dict[str, pd.DataFrame]:
"""Load all metrics from Snowflake."""
return {
"users": load_metric_from_snowflake("users"),
"sessions": load_metric_from_snowflake("sessions"),
"revenue": load_metric_from_snowflake("revenue"),
"conversions": load_metric_from_snowflake("conversions"),
}
# =============================================================================
# Chart Utilities
# =============================================================================
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
"""Filter dataframe by time range."""
if time_range == "All" or df.empty:
return df
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
max_date = df[x_col].max()
if time_range == "1M":
min_date = max_date - timedelta(days=30)
elif time_range == "6M":
min_date = max_date - timedelta(days=180)
elif time_range == "1Y":
min_date = max_date - timedelta(days=365)
elif time_range == "QTD":
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
elif time_range == "YTD":
min_date = pd.Timestamp(date(max_date.year, 1, 1))
else:
return df
return df[df[x_col] >= min_date]
def render_line_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a multi-line chart."""
# Melt for Altair
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
# Map to labels
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_line()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
strokeDash=alt.condition(
alt.datum.series == "7-day MA",
alt.value([5, 5]),
alt.value([0]),
),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_area_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a stacked area chart."""
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_area(opacity=0.6, line=True)
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_bar_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a bar chart (weekly aggregation for readability)."""
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
df["week"] = df[x_col].dt.to_period("W").dt.start_time
# Aggregate by week
agg_df = df.groupby("week")[y_cols].mean().reset_index()
melted = agg_df.melt(
id_vars=["week"],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_bar(opacity=0.8)
.encode(
x=alt.X("week:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
xOffset="series:N",
tooltip=[
alt.Tooltip("week:T", title="Week", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_point_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a scatter/point chart with trend line."""
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
points = (
alt.Chart(melted)
.mark_point(opacity=0.5, size=20)
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
)
# Add trend line for 7-day MA only
trend = (
alt.Chart(melted[melted["series"] == "7-day MA"])
.mark_line(strokeDash=[5, 5], strokeWidth=2)
.encode(
x=alt.X(f"{x_col}:T"),
y=alt.Y("value:Q"),
color=alt.Color("series:N"),
)
)
return (points + trend).properties(height=height)
# =============================================================================
# Metric Card Component
# =============================================================================
def metric_card(
title: str,
df: pd.DataFrame,
key_prefix: str,
chart_type: str = "line",
):
"""Display a metric card with chart/table toggle and popover filters.
Args:
title: Card title
df: DataFrame with ds, daily_value, value_7d_ma columns
key_prefix: Unique prefix for widget keys
chart_type: One of "line", "area", "bar", "point"
"""
chart_renderers = {
"line": render_line_chart,
"area": render_area_chart,
"bar": render_bar_chart,
"point": render_point_chart,
}
render_chart = chart_renderers.get(chart_type, render_line_chart)
with st.container(border=True):
# Header row with title, view toggle, and filters
with st.container(
horizontal=True,
horizontal_alignment="distribute",
vertical_alignment="center",
):
st.markdown(f"**{title}**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key=f"{key_prefix}_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["Daily", "7-day MA"],
selection_mode="multi",
key=f"{key_prefix}_lines",
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key=f"{key_prefix}_time",
)
# Apply filters
line_options = line_options or ["7-day MA"]
filtered_df = filter_by_time_range(df, "ds", time_range)
# Determine which columns to show
y_cols = []
labels = []
if "Daily" in line_options:
y_cols.append("daily_value")
labels.append("Daily")
if "7-day MA" in line_options:
y_cols.append("value_7d_ma")
labels.append("7-day MA")
# Render view
if "table" in (view_mode or ""):
st.dataframe(
filtered_df,
height=CHART_HEIGHT,
hide_index=True,
)
else:
if y_cols:
st.altair_chart(
render_chart(filtered_df, "ds", y_cols, labels),
)
else:
st.info("Select at least one line option.")
# =============================================================================
# Page Header Component
# =============================================================================
def render_page_header(title: str):
"""Render page header with title and reset button."""
with st.container(
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
):
st.markdown(title)
if st.button(":material/restart_alt: Reset", type="tertiary"):
st.session_state.clear()
st.rerun()
# =============================================================================
# Page Layout
# =============================================================================
# Load data from Snowflake
metrics_data = load_all_metrics()
# Page header
render_page_header("# :material/monitoring: Metrics Dashboard")
st.caption(":material/cloud: Powered by Snowflake")
# Row 1: Users and Sessions
row1 = st.columns(2)
with row1[0]:
metric_card("Active Users", metrics_data["users"], "users", chart_type="line")
with row1[1]:
metric_card("Sessions", metrics_data["sessions"], "sessions", chart_type="area")
# Row 2: Revenue and Conversions
row2 = st.columns(2)
with row2[0]:
metric_card("Revenue", metrics_data["revenue"], "revenue", chart_type="bar")
with row2[1]:
metric_card("Conversions", metrics_data["conversions"], "conversions", chart_type="point")
@@ -0,0 +1,12 @@
[project]
name = "dashboard-metrics"
version = "1.0.0"
description = "A metrics dashboard template showing time series with sparklines and filtering"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"numpy>=1.26.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,426 @@
"""
Metrics Dashboard Template
A comprehensive metrics dashboard demonstrating:
- Time series visualization with Altair (line, area, bar, point charts)
- Metric cards with chart/table toggle and popover filters
- Time range filtering (1M, 6M, 1Y, QTD, YTD, All)
- Line options (Daily, 7-day MA)
This template uses synthetic data. Replace the generate_*_data() functions
with your own data sources (e.g., Snowflake queries, APIs, etc.)
"""
from datetime import date, timedelta
import numpy as np
import pandas as pd
import streamlit as st
import altair as alt
st.set_page_config(
page_title="Metrics Dashboard",
page_icon=":material/monitoring:",
layout="wide",
)
# =============================================================================
# Constants
# =============================================================================
TIME_RANGES = ["1M", "6M", "1Y", "QTD", "YTD", "All"]
CHART_HEIGHT = 300
# =============================================================================
# Synthetic Data Generation (Replace with your data source)
# =============================================================================
def generate_metric_data(
metric_name: str,
start_date: date,
end_date: date,
base_value: float = 1000,
growth_rate: float = 0.001,
noise_factor: float = 0.1,
) -> pd.DataFrame:
"""Generate synthetic time series data for a metric.
Replace this function with your actual data source, e.g.:
- Snowflake query
- API call
- Database query
"""
np.random.seed(hash(metric_name) % 2**32)
dates = pd.date_range(start=start_date, end=end_date, freq="D")
n_days = len(dates)
# Base trend with growth
trend = base_value * (1 + growth_rate) ** np.arange(n_days)
# Add weekly seasonality (lower on weekends)
day_of_week = dates.dayofweek
seasonality = np.where(day_of_week >= 5, 0.7, 1.0)
trend = trend * seasonality
# Add noise
noise = np.random.normal(1, noise_factor, n_days)
values = trend * noise
# Calculate rolling averages
df = pd.DataFrame({
"ds": dates,
"daily_value": values,
})
df["value_7d_ma"] = df["daily_value"].rolling(7, min_periods=1).mean()
return df
@st.cache_data(ttl=3600)
def load_all_metrics() -> dict[str, pd.DataFrame]:
"""Load all metrics data. Replace with your data loading logic."""
end_date = date.today() - timedelta(days=1)
start_date = end_date - timedelta(days=730) # 2 years of data
return {
"users": generate_metric_data("users", start_date, end_date, base_value=5000, growth_rate=0.002),
"sessions": generate_metric_data("sessions", start_date, end_date, base_value=15000, growth_rate=0.003),
"revenue": generate_metric_data("revenue", start_date, end_date, base_value=50000, growth_rate=0.001),
"conversions": generate_metric_data("conversions", start_date, end_date, base_value=500, growth_rate=0.0015),
}
# =============================================================================
# Chart Utilities
# =============================================================================
def filter_by_time_range(df: pd.DataFrame, x_col: str, time_range: str) -> pd.DataFrame:
"""Filter dataframe by time range."""
if time_range == "All" or df.empty:
return df
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
max_date = df[x_col].max()
if time_range == "1M":
min_date = max_date - timedelta(days=30)
elif time_range == "6M":
min_date = max_date - timedelta(days=180)
elif time_range == "1Y":
min_date = max_date - timedelta(days=365)
elif time_range == "QTD":
quarter_month = ((max_date.month - 1) // 3) * 3 + 1
min_date = pd.Timestamp(date(max_date.year, quarter_month, 1))
elif time_range == "YTD":
min_date = pd.Timestamp(date(max_date.year, 1, 1))
else:
return df
return df[df[x_col] >= min_date]
def render_line_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a multi-line chart."""
# Melt for Altair
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
# Map to labels
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_line()
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
strokeDash=alt.condition(
alt.datum.series == "7-day MA",
alt.value([5, 5]),
alt.value([0]),
),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_area_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a stacked area chart."""
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_area(opacity=0.6, line=True)
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_bar_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a bar chart (weekly aggregation for readability)."""
df = df.copy()
df[x_col] = pd.to_datetime(df[x_col])
df["week"] = df[x_col].dt.to_period("W").dt.start_time
# Aggregate by week
agg_df = df.groupby("week")[y_cols].mean().reset_index()
melted = agg_df.melt(
id_vars=["week"],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
chart = (
alt.Chart(melted)
.mark_bar(opacity=0.8)
.encode(
x=alt.X("week:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
xOffset="series:N",
tooltip=[
alt.Tooltip("week:T", title="Week", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
.properties(height=height)
)
return chart
def render_point_chart(
df: pd.DataFrame,
x_col: str,
y_cols: list[str],
labels: list[str],
height: int = CHART_HEIGHT,
) -> alt.Chart:
"""Render a scatter/point chart with trend line."""
melted = df.melt(
id_vars=[x_col],
value_vars=y_cols,
var_name="series",
value_name="value",
)
label_map = dict(zip(y_cols, labels))
melted["series"] = melted["series"].map(label_map)
points = (
alt.Chart(melted)
.mark_point(opacity=0.5, size=20)
.encode(
x=alt.X(f"{x_col}:T", title=None),
y=alt.Y("value:Q", title=None, scale=alt.Scale(zero=False)),
color=alt.Color("series:N", title=None, legend=alt.Legend(orient="bottom")),
tooltip=[
alt.Tooltip(f"{x_col}:T", title="Date", format="%Y-%m-%d"),
alt.Tooltip("series:N", title="Series"),
alt.Tooltip("value:Q", title="Value", format=",.0f"),
],
)
)
# Add trend line for 7-day MA only
trend = (
alt.Chart(melted[melted["series"] == "7-day MA"])
.mark_line(strokeDash=[5, 5], strokeWidth=2)
.encode(
x=alt.X(f"{x_col}:T"),
y=alt.Y("value:Q"),
color=alt.Color("series:N"),
)
)
return (points + trend).properties(height=height)
# =============================================================================
# Metric Card Component
# =============================================================================
def metric_card(
title: str,
df: pd.DataFrame,
key_prefix: str,
chart_type: str = "line",
):
"""Display a metric card with chart/table toggle and popover filters.
Args:
title: Card title
df: DataFrame with ds, daily_value, value_7d_ma columns
key_prefix: Unique prefix for widget keys
chart_type: One of "line", "area", "bar", "point"
"""
chart_renderers = {
"line": render_line_chart,
"area": render_area_chart,
"bar": render_bar_chart,
"point": render_point_chart,
}
render_chart = chart_renderers.get(chart_type, render_line_chart)
with st.container(border=True):
# Header row with title, view toggle, and filters
with st.container(
horizontal=True,
horizontal_alignment="distribute",
vertical_alignment="center",
):
st.markdown(f"**{title}**")
view_mode = st.segmented_control(
"View",
options=[":material/show_chart:", ":material/table:"],
default=":material/show_chart:",
key=f"{key_prefix}_view",
label_visibility="collapsed",
)
with st.popover("Filters", type="tertiary"):
line_options = st.pills(
"Lines",
options=["Daily", "7-day MA"],
default=["Daily", "7-day MA"],
selection_mode="multi",
key=f"{key_prefix}_lines",
)
time_range = st.segmented_control(
"Time range",
options=TIME_RANGES,
default="All",
key=f"{key_prefix}_time",
)
# Apply filters
line_options = line_options or ["7-day MA"]
filtered_df = filter_by_time_range(df, "ds", time_range)
# Determine which columns to show
y_cols = []
labels = []
if "Daily" in line_options:
y_cols.append("daily_value")
labels.append("Daily")
if "7-day MA" in line_options:
y_cols.append("value_7d_ma")
labels.append("7-day MA")
# Render view
if "table" in (view_mode or ""):
st.dataframe(
filtered_df,
height=CHART_HEIGHT,
hide_index=True,
)
else:
if y_cols:
st.altair_chart(
render_chart(filtered_df, "ds", y_cols, labels),
)
else:
st.info("Select at least one line option.")
# =============================================================================
# Page Header Component
# =============================================================================
def render_page_header(title: str):
"""Render page header with title and reset button."""
with st.container(
horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"
):
st.markdown(title)
if st.button(":material/restart_alt: Reset", type="tertiary"):
st.session_state.clear()
st.rerun()
# =============================================================================
# Page Layout
# =============================================================================
# Load data (cached)
metrics_data = load_all_metrics()
# Page header
render_page_header("# :material/monitoring: Metrics Dashboard")
# Row 1: Users and Sessions
row1 = st.columns(2)
with row1[0]:
metric_card("Active Users", metrics_data["users"], "users", chart_type="line")
with row1[1]:
metric_card("Sessions", metrics_data["sessions"], "sessions", chart_type="area")
# Row 2: Revenue and Conversions
row2 = st.columns(2)
with row2[0]:
metric_card("Revenue", metrics_data["revenue"], "revenue", chart_type="bar")
with row2[1]:
metric_card("Conversions", metrics_data["conversions"], "conversions", chart_type="point")
@@ -0,0 +1,12 @@
[project]
name = "dashboard-seattle-weather"
version = "1.0.0"
description = "An example dashboard exploring the Seattle Weather dataset"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
"vega-datasets>=0.9.0",
]
@@ -0,0 +1,252 @@
import streamlit as st
import altair as alt
import vega_datasets
full_df = vega_datasets.data("seattle_weather")
st.set_page_config(
# Title and icon for the browser's tab bar:
page_title="Seattle Weather",
page_icon=":mostly_sunny:",
# Make the content take up the width of the page:
layout="wide",
)
"""
# Seattle Weather
Let's explore the [classic Seattle Weather
dataset](https://altair-viz.github.io/case_studies/exploring-weather.html)!
"""
"" # Add a little vertical space. Same as st.write("").
""
"""
## 2015 Summary
"""
""
df_2015 = full_df[full_df["date"].dt.year == 2015]
df_2014 = full_df[full_df["date"].dt.year == 2014]
max_temp_2015 = df_2015["temp_max"].max()
max_temp_2014 = df_2014["temp_max"].max()
min_temp_2015 = df_2015["temp_min"].min()
min_temp_2014 = df_2014["temp_min"].min()
max_wind_2015 = df_2015["wind"].max()
max_wind_2014 = df_2014["wind"].max()
min_wind_2015 = df_2015["wind"].min()
min_wind_2014 = df_2014["wind"].min()
max_prec_2015 = df_2015["precipitation"].max()
max_prec_2014 = df_2014["precipitation"].max()
min_prec_2015 = df_2015["precipitation"].min()
min_prec_2014 = df_2014["precipitation"].min()
with st.container(horizontal=True, gap="medium"):
cols = st.columns(2, gap="medium", width=300)
with cols[0]:
st.metric(
"Max temperature",
f"{max_temp_2015:0.1f}C",
delta=f"{max_temp_2015 - max_temp_2014:0.1f}C",
width="content",
)
with cols[1]:
st.metric(
"Min temperature",
f"{min_temp_2015:0.1f}C",
delta=f"{min_temp_2015 - min_temp_2014:0.1f}C",
width="content",
)
cols = st.columns(2, gap="medium", width=300)
with cols[0]:
st.metric(
"Max precipitation",
f"{max_prec_2015:0.1f}mm",
delta=f"{max_prec_2015 - max_prec_2014:0.1f}mm",
width="content",
)
with cols[1]:
st.metric(
"Min precipitation",
f"{min_prec_2015:0.1f}mm",
delta=f"{min_prec_2015 - min_prec_2014:0.1f}mm",
width="content",
)
cols = st.columns(2, gap="medium", width=300)
with cols[0]:
st.metric(
"Max wind",
f"{max_wind_2015:0.1f}m/s",
delta=f"{max_wind_2015 - max_wind_2014:0.1f}m/s",
width="content",
)
with cols[1]:
st.metric(
"Min wind",
f"{min_wind_2015:0.1f}m/s",
delta=f"{min_wind_2015 - min_wind_2014:0.1f}m/s",
width="content",
)
weather_icons = {
"sun": "sunny",
"snow": "weather_snowy",
"rain": "rainy",
"fog": "foggy",
"drizzle": "rainy",
}
cols = st.columns(2, gap="large")
with cols[0]:
weather_name = (
full_df["weather"].value_counts().head(1).reset_index()["weather"][0]
)
st.metric(
"Most common weather",
f":material/{weather_icons[weather_name]}: {weather_name.upper()}",
)
with cols[1]:
weather_name = (
full_df["weather"].value_counts().tail(1).reset_index()["weather"][0]
)
st.metric(
"Least common weather",
f":material/{weather_icons[weather_name]}: {weather_name.upper()}",
)
""
""
"""
## Compare different years
"""
YEARS = full_df["date"].dt.year.unique()
selected_years = st.pills(
"Years to compare", YEARS, default=YEARS, selection_mode="multi"
)
if not selected_years:
st.warning("You must select at least 1 year.", icon=":material/warning:")
df = full_df[full_df["date"].dt.year.isin(selected_years)]
cols = st.columns([3, 1])
with cols[0].container(border=True, height="stretch"):
"### 🌡️ Temperature"
st.altair_chart(
alt.Chart(df)
.mark_bar(width=1)
.encode(
alt.X("monthdate(date):T").title("date"),
alt.Y("temp_max:Q").title("temperature range (C)"),
alt.Y2("temp_min:Q"),
alt.Color("year(date):N").title("year"),
alt.XOffset("year(date):N"),
tooltip=[
alt.Tooltip("monthdate(date):T", title="Date"),
alt.Tooltip("temp_max:Q", title="Max Temp (C)"),
alt.Tooltip("temp_min:Q", title="Min Temp (C)"),
alt.Tooltip("year(date):N", title="Year"),
],
)
.configure_legend(orient="bottom")
)
with cols[1].container(border=True, height="stretch"):
"### Weather distribution"
st.altair_chart(
alt.Chart(df)
.mark_arc()
.encode(
alt.Theta("count()"),
alt.Color("weather:N"),
)
.configure_legend(orient="bottom")
)
cols = st.columns(2)
with cols[0].container(border=True, height="stretch"):
"### 💨 Wind"
# Prepare data for st.line_chart - pivot by year
wind_df = df.copy()
wind_df["month_day"] = wind_df["date"].dt.strftime("%m-%d")
wind_df["year"] = wind_df["date"].dt.year
# Calculate 14-day rolling average per year
wind_pivot = wind_df.pivot_table(
index="month_day",
columns="year",
values="wind",
aggfunc="mean"
).sort_index()
st.line_chart(wind_pivot, height=300)
with cols[1].container(border=True, height="stretch"):
"### 🌧️ Precipitation"
st.altair_chart(
alt.Chart(df)
.mark_bar()
.encode(
alt.X("month(date):O").title("month"),
alt.Y("sum(precipitation):Q").title("precipitation (mm)"),
alt.Color("year(date):N").title("year"),
tooltip=[
alt.Tooltip("month(date):O", title="Month"),
alt.Tooltip("sum(precipitation):Q", title="Precipitation (mm)"),
alt.Tooltip("year(date):N", title="Year"),
],
)
.configure_legend(orient="bottom")
)
cols = st.columns(2)
with cols[0].container(border=True, height="stretch"):
"### Monthly weather breakdown"
""
st.altair_chart(
alt.Chart(df)
.mark_bar()
.encode(
alt.X("month(date):O", title="month"),
alt.Y("count():Q", title="days").stack("normalize"),
alt.Color("weather:N"),
)
.configure_legend(orient="bottom")
)
with cols[1].container(border=True, height="stretch"):
"### Raw data"
st.dataframe(df)
@@ -0,0 +1,11 @@
[project]
name = "dashboard-stock-peers-snowflake"
version = "1.0.0"
description = "Stock peer analysis dashboard with Snowflake connection"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,18 @@
definition_version: 2
entities:
DASHBOARD_STOCK_PEERS_SNOWFLAKE:
type: streamlit
identifier:
name: DASHBOARD_STOCK_PEERS_SNOWFLAKE
database: <FROM_CONNECTION> # Use: snow connection list
schema: <FROM_CONNECTION>
query_warehouse: <FROM_CONNECTION>
runtime_name: SYSTEM$ST_CONTAINER_RUNTIME_PY3_11
# List available: SHOW EXTERNAL ACCESS INTEGRATIONS;
# Common: PYPI_ACCESS_INTEGRATION (verify exists)
external_access_integrations:
- <YOUR_PYPI_INTEGRATION>
main_file: streamlit_app.py
artifacts:
- streamlit_app.py
- pyproject.toml
@@ -0,0 +1,384 @@
"""
Stock Peer Analysis Dashboard (Snowflake Edition)
A stock comparison dashboard demonstrating:
- Snowflake connection via st.connection("snowflake")
- Generating synthetic stock data in Snowflake
- Normalized price comparison charts
- Individual stock vs peer average analysis
This template uses synthetic stock data generated in Snowflake.
Replace the synthetic query with your actual stock data table.
"""
import streamlit as st
import pandas as pd
import altair as alt
st.set_page_config(
page_title="Stock peer analysis dashboard",
page_icon=":chart_with_upwards_trend:",
layout="wide",
)
"""
# :material/query_stats: Stock peer analysis
Easily compare stocks against others in their peer group.
"""
"" # Add some space.
# =============================================================================
# Snowflake Connection
# =============================================================================
def get_snowflake_connection():
"""Get Snowflake connection via st.connection.
Displays an error and stops the app if the connection fails.
"""
try:
return st.connection("snowflake")
except Exception as e:
st.error(f"Failed to connect to Snowflake: {e}")
st.info(
"Make sure you have configured your Snowflake connection in "
"`.streamlit/secrets.toml` or via environment variables."
)
st.stop()
# =============================================================================
# Constants and Configuration
# =============================================================================
STOCKS = [
"AAPL", "ABBV", "ACN", "ADBE", "ADP", "AMD", "AMGN", "AMT", "AMZN", "APD",
"AVGO", "AXP", "BA", "BK", "BKNG", "BMY", "BSX", "C", "CAT", "CI",
"CL", "CMCSA", "COST", "CRM", "CSCO", "CVX", "DE", "DHR", "DIS", "DUK",
"ELV", "EOG", "EQR", "FDX", "GD", "GE", "GILD", "GOOG", "GOOGL", "HD",
"HON", "HUM", "IBM", "ICE", "INTC", "ISRG", "JNJ", "JPM", "KO", "LIN",
"LLY", "LMT", "LOW", "MA", "MCD", "MDLZ", "META", "MMC", "MO", "MRK",
"MSFT", "NEE", "NFLX", "NKE", "NOW", "NVDA", "ORCL", "PEP", "PFE", "PG",
"PLD", "PM", "PSA", "REGN", "RTX", "SBUX", "SCHW", "SLB", "SO", "SPGI",
"T", "TJX", "TMO", "TSLA", "TXN", "UNH", "UNP", "UPS", "V", "VZ",
"WFC", "WM", "WMT", "XOM",
]
# Base prices for synthetic data (approximate real values for realism)
STOCK_BASE_PRICES = {
"AAPL": 175, "MSFT": 380, "GOOGL": 140, "AMZN": 180, "NVDA": 500,
"META": 350, "TSLA": 250, "JPM": 170, "V": 280, "UNH": 520,
"HD": 350, "PG": 160, "MA": 450, "COST": 580, "ABBV": 170,
"MRK": 120, "AVGO": 900, "PEP": 180, "KO": 60, "TMO": 550,
"ADBE": 550, "CRM": 280, "CSCO": 50, "ACN": 340, "NKE": 100,
}
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
# Time horizon mapping
HORIZON_MAP = {
"1 Month": 30,
"3 Months": 90,
"6 Months": 180,
"1 Year": 365,
"2 Years": 730,
}
def stocks_to_str(stocks):
return ",".join(stocks)
# =============================================================================
# Data Loading
# =============================================================================
# -----------------------------------------------------------------------------
# PRODUCTION PATTERN: Use parameterized queries for real stock data
# -----------------------------------------------------------------------------
# For production use with actual stock tables, use parameterized queries:
#
# STOCK_QUERY = """
# SELECT trade_date AS date, ticker, close_price
# FROM stock_prices
# WHERE ticker = ANY(:tickers)
# AND trade_date >= DATEADD(day, -:days, CURRENT_DATE())
# ORDER BY trade_date, ticker
# """
#
# def load_stock_data(tickers: list[str], days: int) -> pd.DataFrame:
# conn = get_snowflake_connection()
# df = conn.query(
# STOCK_QUERY,
# params={"tickers": tickers, "days": days}
# )
# return df
#
# The synthetic data generation below uses f-strings for the VALUES clause
# which cannot be parameterized. This is acceptable for demo/synthetic data
# but should NOT be used with user input in production.
# -----------------------------------------------------------------------------
def generate_stock_data_query(tickers: list[str], days: int) -> str:
"""Generate SQL query that creates synthetic stock price data.
NOTE: This uses f-strings for VALUES clause construction which is acceptable
for synthetic data generation with controlled inputs. For production apps
with real tables, always use parameterized queries as shown above.
"""
# Build ticker values and base prices (controlled data, not user input)
ticker_values = []
for ticker in tickers:
base_price = STOCK_BASE_PRICES.get(ticker, 100 + hash(ticker) % 400)
growth_rate = 0.0003 + (hash(ticker) % 10) * 0.00005
volatility = 0.02 + (hash(ticker) % 5) * 0.005
ticker_values.append(f"('{ticker}', {base_price}, {growth_rate}, {volatility})")
tickers_cte = ", ".join(ticker_values)
return f"""
WITH tickers AS (
SELECT column1 AS ticker, column2 AS base_price, column3 AS growth_rate, column4 AS volatility
FROM VALUES {tickers_cte}
),
date_series AS (
SELECT DATEADD(day, -seq4(), CURRENT_DATE() - 1) AS trade_date
FROM TABLE(GENERATOR(ROWCOUNT => {days}))
),
raw_prices AS (
SELECT
d.trade_date,
t.ticker,
t.base_price * POWER(1 + t.growth_rate, DATEDIFF(day, DATEADD(day, -{days}, CURRENT_DATE()), d.trade_date))
* (1 + (RANDOM() / 10000000000000000000.0 - 0.5) * t.volatility * 2) AS close_price
FROM date_series d
CROSS JOIN tickers t
WHERE DAYOFWEEK(d.trade_date) NOT IN (0, 6) -- Exclude weekends
)
SELECT
trade_date AS date,
ticker,
ROUND(close_price, 2) AS close_price
FROM raw_prices
ORDER BY trade_date, ticker
"""
@st.cache_data(ttl=3600, show_spinner="Loading stock data from Snowflake...")
def load_stock_data(tickers: list[str], days: int) -> pd.DataFrame:
"""Load stock price data from Snowflake."""
conn = get_snowflake_connection()
query = generate_stock_data_query(tickers, days)
df = conn.query(query)
df.columns = df.columns.str.lower()
# Pivot to get tickers as columns
pivoted = df.pivot(index="date", columns="ticker", values="close_price")
pivoted.index = pd.to_datetime(pivoted.index)
return pivoted
# =============================================================================
# Session State and Query Params
# =============================================================================
if "tickers_input" not in st.session_state:
st.session_state.tickers_input = st.query_params.get(
"stocks", stocks_to_str(DEFAULT_STOCKS)
).split(",")
# =============================================================================
# Page Layout
# =============================================================================
# Check Snowflake connection
get_snowflake_connection()
cols = st.columns([1, 3])
top_left_cell = cols[0].container(
border=True, height="stretch", vertical_alignment="center"
)
with top_left_cell:
# Selectbox for stock tickers
tickers = st.multiselect(
"Stock tickers",
options=sorted(set(STOCKS) | set(st.session_state.tickers_input)),
default=st.session_state.tickers_input,
placeholder="Choose stocks to compare. Example: NVDA",
accept_new_options=True,
)
# Time horizon selector
horizon = st.pills(
"Time horizon",
options=list(HORIZON_MAP.keys()),
default="6 Months",
)
tickers = [t.upper() for t in tickers]
# Update query param when text input changes
if tickers:
st.query_params["stocks"] = stocks_to_str(tickers)
else:
st.query_params.pop("stocks", None)
if not tickers:
top_left_cell.info("Pick some stocks to compare", icon=":material/info:")
st.stop()
right_cell = cols[1].container(
border=True, height="stretch", vertical_alignment="center"
)
# Load the data from Snowflake
try:
data = load_stock_data(tickers, HORIZON_MAP[horizon])
except Exception as e:
st.error(f"Error loading stock data: {e}")
st.stop()
# Check for missing data
missing_tickers = [t for t in tickers if t not in data.columns]
if missing_tickers:
st.warning(f"No data available for: {', '.join(missing_tickers)}")
# Filter to available tickers
tickers = [t for t in tickers if t in data.columns]
if not tickers:
st.stop()
# Normalize prices (start at 1)
normalized = data[tickers].div(data[tickers].iloc[0])
latest_norm_values = {normalized[ticker].iat[-1]: ticker for ticker in tickers}
max_norm_value = max(latest_norm_values.items())
min_norm_value = min(latest_norm_values.items())
bottom_left_cell = cols[0].container(
border=True, height="stretch", vertical_alignment="center"
)
with bottom_left_cell:
metric_cols = st.columns(2)
metric_cols[0].metric(
"Best stock",
max_norm_value[1],
delta=f"{round((max_norm_value[0] - 1) * 100)}%",
width="content",
)
metric_cols[1].metric(
"Worst stock",
min_norm_value[1],
delta=f"{round((min_norm_value[0] - 1) * 100)}%",
width="content",
)
# Plot normalized prices
with right_cell:
st.altair_chart(
alt.Chart(
normalized.reset_index().melt(
id_vars=["date"], var_name="Stock", value_name="Normalized price"
)
)
.mark_line()
.encode(
alt.X("date:T", title="Date"),
alt.Y("Normalized price:Q").scale(zero=False),
alt.Color("Stock:N"),
)
.properties(height=400)
)
""
""
# Plot individual stock vs peer average
"""
## Individual stocks vs peer average
For the analysis below, the "peer average" when analyzing stock X always
excludes X itself.
"""
if len(tickers) <= 1:
st.warning("Pick 2 or more tickers to compare them")
st.stop()
NUM_COLS = 4
chart_cols = st.columns(NUM_COLS)
for i, ticker in enumerate(tickers):
# Calculate peer average (excluding current stock)
peers = normalized.drop(columns=[ticker])
peer_avg = peers.mean(axis=1)
# Create DataFrame with peer average
plot_data = pd.DataFrame(
{
"Date": normalized.index,
ticker: normalized[ticker],
"Peer average": peer_avg,
}
).melt(id_vars=["Date"], var_name="Series", value_name="Price")
chart = (
alt.Chart(plot_data)
.mark_line()
.encode(
alt.X("Date:T"),
alt.Y("Price:Q").scale(zero=False),
alt.Color(
"Series:N",
scale=alt.Scale(domain=[ticker, "Peer average"], range=["red", "gray"]),
legend=alt.Legend(orient="bottom"),
),
alt.Tooltip(["Date", "Series", "Price"]),
)
.properties(title=f"{ticker} vs peer average", height=300)
)
cell = chart_cols[(i * 2) % NUM_COLS].container(border=True)
cell.write("")
cell.altair_chart(chart)
# Create Delta chart
plot_data = pd.DataFrame(
{
"Date": normalized.index,
"Delta": normalized[ticker] - peer_avg,
}
)
chart = (
alt.Chart(plot_data)
.mark_area()
.encode(
alt.X("Date:T"),
alt.Y("Delta:Q").scale(zero=False),
)
.properties(title=f"{ticker} minus peer average", height=300)
)
cell = chart_cols[(i * 2 + 1) % NUM_COLS].container(border=True)
cell.write("")
cell.altair_chart(chart)
""
""
"""
## Raw data
"""
st.caption(":material/cloud: Data loaded from Snowflake (synthetic)")
data[tickers]
@@ -0,0 +1,12 @@
[project]
name = "dashboard-stock-peers"
version = "1.0.0"
description = "Stock peer analysis dashboard: easily compare stocks against others in their peer group"
requires-python = ">=3.11"
dependencies = [
"altair>=5.5.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
"yfinance>=0.2.55",
]
@@ -0,0 +1,342 @@
import streamlit as st
import yfinance as yf
import pandas as pd
import altair as alt
st.set_page_config(
page_title="Stock peer analysis dashboard",
page_icon=":chart_with_upwards_trend:",
layout="wide",
)
"""
# :material/query_stats: Stock peer analysis
Easily compare stocks against others in their peer group.
"""
"" # Add some space.
cols = st.columns([1, 3])
# Will declare right cell later to avoid showing it when no data.
STOCKS = [
"AAPL",
"ABBV",
"ACN",
"ADBE",
"ADP",
"AMD",
"AMGN",
"AMT",
"AMZN",
"APD",
"AVGO",
"AXP",
"BA",
"BK",
"BKNG",
"BMY",
"BRK.B",
"BSX",
"C",
"CAT",
"CI",
"CL",
"CMCSA",
"COST",
"CRM",
"CSCO",
"CVX",
"DE",
"DHR",
"DIS",
"DUK",
"ELV",
"EOG",
"EQR",
"FDX",
"GD",
"GE",
"GILD",
"GOOG",
"GOOGL",
"HD",
"HON",
"HUM",
"IBM",
"ICE",
"INTC",
"ISRG",
"JNJ",
"JPM",
"KO",
"LIN",
"LLY",
"LMT",
"LOW",
"MA",
"MCD",
"MDLZ",
"META",
"MMC",
"MO",
"MRK",
"MSFT",
"NEE",
"NFLX",
"NKE",
"NOW",
"NVDA",
"ORCL",
"PEP",
"PFE",
"PG",
"PLD",
"PM",
"PSA",
"REGN",
"RTX",
"SBUX",
"SCHW",
"SLB",
"SO",
"SPGI",
"T",
"TJX",
"TMO",
"TSLA",
"TXN",
"UNH",
"UNP",
"UPS",
"V",
"VZ",
"WFC",
"WM",
"WMT",
"XOM",
]
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
def stocks_to_str(stocks):
return ",".join(stocks)
if "tickers_input" not in st.session_state:
st.session_state.tickers_input = st.query_params.get(
"stocks", stocks_to_str(DEFAULT_STOCKS)
).split(",")
# Callback to update query param when input changes
def update_query_param():
if st.session_state.tickers_input:
st.query_params["stocks"] = stocks_to_str(st.session_state.tickers_input)
else:
st.query_params.pop("stocks", None)
top_left_cell = cols[0].container(
border=True, height="stretch", vertical_alignment="center"
)
with top_left_cell:
# Selectbox for stock tickers
tickers = st.multiselect(
"Stock tickers",
options=sorted(set(STOCKS) | set(st.session_state.tickers_input)),
default=st.session_state.tickers_input,
placeholder="Choose stocks to compare. Example: NVDA",
accept_new_options=True,
)
# Time horizon selector
horizon_map = {
"1 Month": "1mo",
"3 Months": "3mo",
"6 Months": "6mo",
"1 Year": "1y",
"5 Years": "5y",
"10 Years": "10y",
"20 Years": "20y",
}
with top_left_cell:
# Buttons for picking time horizon
horizon = st.pills(
"Time horizon",
options=list(horizon_map.keys()),
default="6 Months",
)
tickers = [t.upper() for t in tickers]
# Update query param when text input changes
if tickers:
st.query_params["stocks"] = stocks_to_str(tickers)
else:
# Clear the param if input is empty
st.query_params.pop("stocks", None)
if not tickers:
top_left_cell.info("Pick some stocks to compare", icon=":material/info:")
st.stop()
right_cell = cols[1].container(
border=True, height="stretch", vertical_alignment="center"
)
@st.cache_resource(show_spinner=False, ttl="6h")
def load_data(tickers, period):
tickers_obj = yf.Tickers(tickers)
data = tickers_obj.history(period=period)
if data is None:
raise RuntimeError("YFinance returned no data.")
return data["Close"]
# Load the data
try:
data = load_data(tickers, horizon_map[horizon])
except yf.exceptions.YFRateLimitError as e:
st.warning("YFinance is rate-limiting us :(\nTry again later.")
load_data.clear() # Remove the bad cache entry.
st.stop()
empty_columns = data.columns[data.isna().all()].tolist()
if empty_columns:
st.error(f"Error loading data for the tickers: {', '.join(empty_columns)}.")
st.stop()
# Normalize prices (start at 1)
normalized = data.div(data.iloc[0])
latest_norm_values = {normalized[ticker].iat[-1]: ticker for ticker in tickers}
max_norm_value = max(latest_norm_values.items())
min_norm_value = min(latest_norm_values.items())
bottom_left_cell = cols[0].container(
border=True, height="stretch", vertical_alignment="center"
)
with bottom_left_cell:
cols = st.columns(2)
cols[0].metric(
"Best stock",
max_norm_value[1],
delta=f"{round(max_norm_value[0] * 100)}%",
width="content",
)
cols[1].metric(
"Worst stock",
min_norm_value[1],
delta=f"{round(min_norm_value[0] * 100)}%",
width="content",
)
# Plot normalized prices
with right_cell:
st.altair_chart(
alt.Chart(
normalized.reset_index().melt(
id_vars=["Date"], var_name="Stock", value_name="Normalized price"
)
)
.mark_line()
.encode(
alt.X("Date:T"),
alt.Y("Normalized price:Q").scale(zero=False),
alt.Color("Stock:N"),
)
.properties(height=400)
)
""
""
# Plot individual stock vs peer average
"""
## Individual stocks vs peer average
For the analysis below, the "peer average" when analyzing stock X always
excludes X itself.
"""
if len(tickers) <= 1:
st.warning("Pick 2 or more tickers to compare them")
st.stop()
NUM_COLS = 4
cols = st.columns(NUM_COLS)
for i, ticker in enumerate(tickers):
# Calculate peer average (excluding current stock)
peers = normalized.drop(columns=[ticker])
peer_avg = peers.mean(axis=1)
# Create DataFrame with peer average.
plot_data = pd.DataFrame(
{
"Date": normalized.index,
ticker: normalized[ticker],
"Peer average": peer_avg,
}
).melt(id_vars=["Date"], var_name="Series", value_name="Price")
chart = (
alt.Chart(plot_data)
.mark_line()
.encode(
alt.X("Date:T"),
alt.Y("Price:Q").scale(zero=False),
alt.Color(
"Series:N",
scale=alt.Scale(domain=[ticker, "Peer average"], range=["red", "gray"]),
legend=alt.Legend(orient="bottom"),
),
alt.Tooltip(["Date", "Series", "Price"]),
)
.properties(title=f"{ticker} vs peer average", height=300)
)
cell = cols[(i * 2) % NUM_COLS].container(border=True)
cell.write("")
cell.altair_chart(chart)
# Create Delta chart
plot_data = pd.DataFrame(
{
"Date": normalized.index,
"Delta": normalized[ticker] - peer_avg,
}
)
chart = (
alt.Chart(plot_data)
.mark_area()
.encode(
alt.X("Date:T"),
alt.Y("Delta:Q").scale(zero=False),
)
.properties(title=f"{ticker} minus peer average", height=300)
)
cell = cols[(i * 2 + 1) % NUM_COLS].container(border=True)
cell.write("")
cell.altair_chart(chart)
""
""
"""
## Raw data
"""
data
@@ -0,0 +1,128 @@
# Streamlit theme templates
Ready-to-use theme templates for Streamlit apps.
## Available themes
| Theme | Base | Primary color | Fonts |
|-------|------|---------------|-------|
| **snowflake** | Light | `#29B5E8` (cyan) | Inter, JetBrains Mono |
| **dracula** | Dark | `#BD93F9` (purple) | Fira Sans, JetBrains Mono |
| **nord** | Dark | `#88C0D0` (frost blue) | Inter, JetBrains Mono |
| **stripe** | Light | `#635BFF` (indigo) | Inter, Source Code Pro |
| **solarized-light** | Light | `#268BD2` (blue) | Source Sans 3, Source Code Pro |
| **spotify** | Dark | `#1DB954` (green) | Inter, Fira Code |
| **github** | Light | `#0969DA` (blue) | Inter, JetBrains Mono |
| **minimal** | Dark | `#6366f1` (indigo) | Inter, JetBrains Mono |
## Quick start
```bash
# Run a theme locally
cd templates/themes/spotify
uv sync
uv run streamlit run streamlit_app.py
```
## Deploying to Snowflake
Before deploying, update `snowflake.yml` with your account-specific resources:
```yaml
# Find available compute pools
SHOW COMPUTE POOLS;
# Find available external access integrations
SHOW EXTERNAL ACCESS INTEGRATIONS;
```
Then edit `snowflake.yml` to replace the placeholders:
- `<YOUR_COMPUTE_POOL>` → e.g., `STREAMLIT_DEDICATED_POOL`
- `<YOUR_PYPI_INTEGRATION>` → e.g., `PYPI_ACCESS_INTEGRATION`
- `<FROM_CONNECTION>` values are filled from your active connection
Deploy with:
```bash
snow streamlit deploy --replace
```
## How Streamlit theming works
A custom theme requires two things:
### 1. Theme configuration in `.streamlit/config.toml`
```toml
[theme]
base = "dark" # "dark" or "light"
primaryColor = "#1DB954" # Buttons, links, highlights
backgroundColor = "#121212" # Main background
secondaryBackgroundColor = "#181818" # Sidebar, cards
textColor = "#FFFFFF" # Main text color
font = "Inter" # Body font
codeFont = "FiraCode" # Code blocks
```
### 2. For Snowflake deployment: local font files
Snowflake doesn't allow remote URL fetches, so fonts must be bundled locally:
```toml
[server]
enableStaticServing = true # Required for static files
[[theme.fontFaces]]
family = "Inter"
url = "app/static/Inter-Regular.ttf" # Note: app/ prefix required
weight = 400
[[theme.fontFaces]]
family = "Inter"
url = "app/static/Inter-Bold.ttf"
weight = 700
```
Font files go in `static/` directory and are referenced with `app/static/` prefix.
### Sidebar theming (optional)
```toml
[theme.sidebar]
backgroundColor = "#181818"
secondaryBackgroundColor = "#121212"
borderColor = "#282828"
```
## Theme file structure
Each theme directory contains:
```
{theme}/
├── .streamlit/config.toml # Theme colors and fonts
├── streamlit_app.py # Demo app showing the theme
├── pyproject.toml # Dependencies
├── snowflake.yml # Snowflake deployment config
└── static/ # Bundled font files (*.ttf)
```
## Dependencies
All themes require Python >=3.11 and use:
- `snowflake-connector-python>=3.3.0` (required — `streamlit[snowflake]` silently skips this on Python 3.12+)
- `streamlit[snowflake]>=1.54.0`
- `altair>=5.5.0`
- `pandas>=2.2.3`
## Font licensing
All bundled fonts are licensed under the [SIL Open Font License 1.1](https://openfontlicense.org/), which permits free use, redistribution, and modification:
| Font | Used by | Source |
|------|---------|--------|
| Inter | snowflake, nord, spotify, github, minimal, stripe | [github.com/rsms/inter](https://github.com/rsms/inter) |
| JetBrains Mono | snowflake, dracula, nord, github, minimal | [github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono) |
| Fira Sans | dracula | [github.com/mozilla/Fira](https://github.com/mozilla/Fira) |
| Fira Code | spotify | [github.com/tonsky/FiraCode](https://github.com/tonsky/FiraCode) |
| Source Sans 3 | solarized-light | [github.com/adobe-fonts/source-sans](https://github.com/adobe-fonts/source-sans) |
| Source Code Pro | solarized-light, stripe | [github.com/adobe-fonts/source-code-pro](https://github.com/adobe-fonts/source-code-pro) |
@@ -0,0 +1,39 @@
# Dracula Theme for Streamlit
# Popular dark theme with vibrant colors on dark background
[theme]
base = "dark"
primaryColor = "#bd93f9"
backgroundColor = "#282a36"
secondaryBackgroundColor = "#21222c"
codeBackgroundColor = "#21222c"
textColor = "#f8f8f2"
linkColor = "#8be9fd"
borderColor = "#44475a"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "8px"
buttonRadius = "8px"
font = "'Fira Sans':https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "0.875rem"
codeTextColor = "#f8f8f2"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [700, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#bd93f9", "#50fa7b", "#ff79c6", "#8be9fd", "#ffb86c", "#ff5555", "#f1fa8c"]
# Dracula color palette
violetColor = "#bd93f9"
greenColor = "#50fa7b"
redColor = "#ff5555"
blueColor = "#8be9fd"
yellowColor = "#f1fa8c"
orangeColor = "#ffb86c"
[theme.sidebar]
backgroundColor = "#21222c"
secondaryBackgroundColor = "#191a21"
codeBackgroundColor = "#191a21"
borderColor = "#44475a"
@@ -0,0 +1,37 @@
# GitHub Theme for Streamlit
# Clean, developer-friendly, functional with signature blue accents
[theme]
primaryColor = "#0969da"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f6f8fa"
codeBackgroundColor = "#f6f8fa"
textColor = "#1F2328"
linkColor = "#0969da"
borderColor = "#d0d7de"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "6px"
buttonRadius = "6px"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "0.85rem"
codeTextColor = "#1F2328"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#0969da", "#1a7f37", "#bf3989", "#8250df", "#cf222e", "#bf8700", "#57606a"]
# GitHub color palette
blueColor = "#0969da"
greenColor = "#1a7f37"
redColor = "#cf222e"
violetColor = "#8250df"
orangeColor = "#bf8700"
[theme.sidebar]
backgroundColor = "#f6f8fa"
secondaryBackgroundColor = "#eaeef2"
codeBackgroundColor = "#eaeef2"
borderColor = "#d0d7de"
@@ -0,0 +1,39 @@
# Minimal Dark Theme for Streamlit
# Clean, distraction-free dark theme with subtle accents
[theme]
base = "dark"
primaryColor = "#6366f1"
backgroundColor = "#18181b"
secondaryBackgroundColor = "#27272a"
codeBackgroundColor = "#27272a"
textColor = "#fafafa"
linkColor = "#818cf8"
borderColor = "#3f3f46"
showWidgetBorder = false
showSidebarBorder = false
baseRadius = "6px"
buttonRadius = "6px"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "0.85rem"
codeTextColor = "#e4e4e7"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 500, 500, 500, 500]
linkUnderline = false
chartCategoricalColors = ["#6366f1", "#8b5cf6", "#ec4899", "#14b8a6", "#f59e0b", "#ef4444", "#22c55e"]
# Color palette
violetColor = "#8b5cf6"
blueColor = "#6366f1"
greenColor = "#22c55e"
yellowColor = "#f59e0b"
orangeColor = "#f97316"
redColor = "#ef4444"
[theme.sidebar]
backgroundColor = "#09090b"
secondaryBackgroundColor = "#18181b"
codeBackgroundColor = "#18181b"
borderColor = "#27272a"
@@ -0,0 +1,39 @@
# Nord Theme for Streamlit
# Arctic, north-bluish color palette with frost-inspired accents
[theme]
base = "dark"
primaryColor = "#88c0d0"
backgroundColor = "#2e3440"
secondaryBackgroundColor = "#3b4252"
codeBackgroundColor = "#3b4252"
textColor = "#eceff4"
linkColor = "#81a1c1"
borderColor = "#4c566a"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "4px"
buttonRadius = "4px"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "0.875rem"
codeTextColor = "#d8dee9"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#88c0d0", "#81a1c1", "#5e81ac", "#a3be8c", "#ebcb8b", "#d08770", "#bf616a"]
# Nord color palette (Frost + Aurora)
blueColor = "#81a1c1"
greenColor = "#a3be8c"
yellowColor = "#ebcb8b"
orangeColor = "#d08770"
redColor = "#bf616a"
violetColor = "#b48ead"
[theme.sidebar]
backgroundColor = "#3b4252"
secondaryBackgroundColor = "#434c5e"
codeBackgroundColor = "#434c5e"
borderColor = "#4c566a"
@@ -0,0 +1,42 @@
# Snowflake Theme for Streamlit
# The Data Cloud company aesthetic - clean, professional, icy blue branding
[theme]
primaryColor = "#29B5E8"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f4f9fc"
codeBackgroundColor = "#e8f4f8"
textColor = "#11567F"
linkColor = "#29B5E8"
borderColor = "#d0e8f2"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "8px"
buttonRadius = "8px"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'JetBrains Mono':https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
codeFontSize = "13px"
codeTextColor = "#11567F"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 500, 500, 500]
linkUnderline = false
chartCategoricalColors = ["#29B5E8", "#FF8B00", "#36B37E", "#6554C0", "#DE350B", "#11567F", "#FFAB00", "#00A3BF"]
# Snowflake color palette
blueColor = "#29B5E8"
greenColor = "#36B37E"
yellowColor = "#FFAB00"
orangeColor = "#FF8B00"
redColor = "#DE350B"
violetColor = "#6554C0"
dataframeBorderColor = "#d0e8f2"
dataframeHeaderBackgroundColor = "#e8f4f8"
[theme.sidebar]
backgroundColor = "#11567F"
secondaryBackgroundColor = "#174D6A"
codeBackgroundColor = "#0E4D6B"
textColor = "#ffffff"
borderColor = "#1E6D94"
@@ -0,0 +1,38 @@
# Solarized Light Theme for Streamlit
# Precision colors designed for readability and reduced eye strain
[theme]
primaryColor = "#268bd2"
backgroundColor = "#fdf6e3"
secondaryBackgroundColor = "#eee8d5"
codeBackgroundColor = "#eee8d5"
textColor = "#657b83"
linkColor = "#268bd2"
borderColor = "#93a1a1"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "4px"
buttonRadius = "4px"
font = "'Source Sans 3':https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&display=swap"
codeFont = "'Source Code Pro':https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500&display=swap"
codeFontSize = "0.875rem"
codeTextColor = "#586e75"
baseFontSize = 14
baseFontWeight = 400
headingFontSizes = ["32px", "24px", "20px", "16px", "14px", "12px"]
headingFontWeights = [600, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#268bd2", "#2aa198", "#859900", "#b58900", "#cb4b16", "#dc322f", "#d33682"]
# Solarized color palette
blueColor = "#268bd2"
greenColor = "#859900"
yellowColor = "#b58900"
orangeColor = "#cb4b16"
redColor = "#dc322f"
violetColor = "#6c71c4"
[theme.sidebar]
backgroundColor = "#eee8d5"
secondaryBackgroundColor = "#fdf6e3"
codeBackgroundColor = "#fdf6e3"
borderColor = "#93a1a1"
@@ -0,0 +1,34 @@
# Spotify Theme for Streamlit
# Bold, energetic, high contrast with signature green
[theme]
base = "dark"
primaryColor = "#1DB954"
backgroundColor = "#191414"
secondaryBackgroundColor = "#282828"
codeBackgroundColor = "#282828"
textColor = "#ffffff"
linkColor = "#1DB954"
borderColor = "#404040"
showWidgetBorder = false
showSidebarBorder = false
baseRadius = "8px"
buttonRadius = "full"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'Fira Code':https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"
codeFontSize = "0.85rem"
baseFontSize = 16
baseFontWeight = 400
headingFontWeights = [800, 700, 700, 600, 600, 600]
headingFontSizes = ["48px", "36px", "28px", "22px", "18px", "16px"]
chartCategoricalColors = ["#1DB954", "#1ED760", "#B3B3B3", "#535353", "#191414", "#FFFFFF", "#509BF5"]
# Spotify color palette
greenColor = "#1DB954"
blueColor = "#509BF5"
grayColor = "#B3B3B3"
[theme.sidebar]
backgroundColor = "#000000"
secondaryBackgroundColor = "#282828"
codeBackgroundColor = "#282828"
borderColor = "#333333"
@@ -0,0 +1,35 @@
# Stripe Theme for Streamlit
# Polished, professional, modern with signature purple/indigo gradients
[theme]
primaryColor = "#635bff"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f6f9fc"
codeBackgroundColor = "#f7f9fc"
textColor = "#425466"
linkColor = "#635bff"
borderColor = "#e3e8ee"
showWidgetBorder = true
showSidebarBorder = true
baseRadius = "8px"
buttonRadius = "8px"
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
codeFont = "'Source Code Pro':https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500&display=swap"
codeFontSize = "0.85rem"
codeTextColor = "#425466"
baseFontSize = 15
baseFontWeight = 400
headingFontSizes = ["40px", "32px", "24px", "20px", "16px", "14px"]
headingFontWeights = [600, 600, 600, 600, 600, 600]
linkUnderline = false
chartCategoricalColors = ["#635bff", "#00d4ff", "#0a2540", "#adbdcc", "#80e9ff", "#7a73ff", "#425466"]
# Stripe color palette
violetColor = "#635bff"
blueColor = "#00d4ff"
grayColor = "#adbdcc"
[theme.sidebar]
backgroundColor = "#f6f9fc"
secondaryBackgroundColor = "#ebeef1"
codeBackgroundColor = "#ebeef1"
borderColor = "#e3e8ee"
@@ -0,0 +1,336 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **{{title}}**")
@@ -0,0 +1,12 @@
# DO NOT EDIT — managed by manage.py, edit _templates/pyproject.toml.tmpl instead
[project]
name = "theme-{{slug}}"
version = "1.0.0"
description = "{{title}} theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"snowflake-connector-python>=3.3.0",
"streamlit[snowflake]>=1.54.0",
]
@@ -0,0 +1,10 @@
[project]
name = "theme-dracula"
version = "1.0.0"
description = "Dracula theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Dracula**")
@@ -0,0 +1,10 @@
[project]
name = "theme-github"
version = "1.0.0"
description = "GitHub theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **GitHub**")
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Manage theme template directories (fully generated from _configs/).
Usage:
python manage.py sync # Regenerate all theme directories
python manage.py check # Verify generated files haven't drifted
python manage.py new NAME # Scaffold a new theme config
"""
import ast
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).parent
SHARED = ROOT / "_shared"
TEMPLATES = ROOT / "_templates"
CONFIGS = ROOT / "_configs"
FONTS = SHARED / "fonts"
MANAGED_HEADER_PY = (
"# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead\n"
)
MANAGED_HEADER_TOML = (
"# DO NOT EDIT — managed by manage.py, edit _configs/{slug}.toml instead\n"
)
GITATTR_START = "# BEGIN managed by manage.py"
GITATTR_END = "# END managed by manage.py"
# ---------------------------------------------------------------------------
# Theme discovery
# ---------------------------------------------------------------------------
TITLE_OVERRIDES = {"github": "GitHub"}
def slug_to_title(slug):
"""Derive a display title from a directory slug: 'solarized-light' -> 'Solarized Light'."""
if slug in TITLE_OVERRIDES:
return TITLE_OVERRIDES[slug]
return " ".join(w.capitalize() for w in slug.split("-"))
def discover_themes():
"""Find themes by scanning _configs/*.toml."""
return [
{"slug": c.stem, "title": slug_to_title(c.stem)}
for c in sorted(CONFIGS.glob("*.toml"))
]
# ---------------------------------------------------------------------------
# Font discovery from config content
# ---------------------------------------------------------------------------
def discover_fonts(config_text):
"""Extract font filenames referenced in config.toml content."""
return re.findall(r'url\s*=\s*["\']app/static/([^"\']+\.(?:ttf|otf|woff2?))["\']', config_text)
# ---------------------------------------------------------------------------
# Content builders
# ---------------------------------------------------------------------------
def expected_app(title):
"""Build expected streamlit_app.py content for a theme."""
source = (SHARED / "streamlit_app.py").read_text()
body = source.replace("{{title}}", title)
# Insert managed header after the module docstring
tree = ast.parse(body)
if ast.get_docstring(tree) is not None:
# The docstring is the first statement; find end of its line
docstring_node = tree.body[0]
end_line = docstring_node.end_lineno # 1-indexed
lines = body.split("\n")
insert_pos = end_line
return "\n".join(lines[:insert_pos]) + "\n" + MANAGED_HEADER_PY + "\n".join(lines[insert_pos:])
return MANAGED_HEADER_PY + body
def expected_config(slug):
"""Build expected .streamlit/config.toml content for a theme."""
source = (CONFIGS / f"{slug}.toml").read_text()
header = MANAGED_HEADER_TOML.replace("{slug}", slug)
return header + source
def expected_from_template(tmpl_path, replacements):
"""Build expected file content from a .tmpl template."""
text = tmpl_path.read_text()
for key, value in replacements.items():
text = text.replace("{{" + key + "}}", value)
return text
# ---------------------------------------------------------------------------
# Sync
# ---------------------------------------------------------------------------
def sync_theme(theme):
"""Regenerate all files for a single theme directory."""
slug = theme["slug"]
title = theme["title"]
identifier = slug.replace("-", "_")
theme_dir = ROOT / slug
# Create directories
theme_dir.mkdir(exist_ok=True)
(theme_dir / ".streamlit").mkdir(exist_ok=True)
(theme_dir / "static").mkdir(exist_ok=True)
# .streamlit/config.toml — from _configs/
(theme_dir / ".streamlit" / "config.toml").write_text(expected_config(slug))
# streamlit_app.py
(theme_dir / "streamlit_app.py").write_text(expected_app(title))
# pyproject.toml
(theme_dir / "pyproject.toml").write_text(
expected_from_template(
TEMPLATES / "pyproject.toml.tmpl",
{"slug": slug, "title": title},
)
)
# snowflake.yml
(theme_dir / "snowflake.yml").write_text(
expected_from_template(
TEMPLATES / "snowflake.yml.tmpl",
{"slug": slug, "title": title, "identifier": identifier},
)
)
# Fonts — copy from _shared/fonts/ based on config references
config_text = (CONFIGS / f"{slug}.toml").read_text()
font_names = discover_fonts(config_text)
static_dir = theme_dir / "static"
for fname in font_names:
src = FONTS / fname
if not src.exists():
print(f" Warning: font {fname} referenced in _configs/{slug}.toml not found in _shared/fonts/", file=sys.stderr)
continue
shutil.copy2(src, static_dir / fname)
def update_gitattributes():
"""Update .gitattributes with entries for generated theme files."""
gitattr_path = ROOT / ".gitattributes"
new_section = "\n".join([
GITATTR_START,
"*/.streamlit/config.toml linguist-generated",
"*/streamlit_app.py linguist-generated",
"*/pyproject.toml linguist-generated",
"*/snowflake.yml linguist-generated",
"*/static/*.ttf linguist-generated",
GITATTR_END,
])
if gitattr_path.exists():
content = gitattr_path.read_text()
if GITATTR_START in content:
start = content.index(GITATTR_START)
end = content.index(GITATTR_END) + len(GITATTR_END)
content = content[:start] + new_section + content[end:]
else:
content = content.rstrip() + "\n\n" + new_section + "\n"
else:
content = new_section + "\n"
gitattr_path.write_text(content)
def cmd_sync():
themes = discover_themes()
for t in themes:
sync_theme(t)
print(f" Synced {t['slug']}/")
# Remove orphaned theme directories (directories not matching any config)
config_slugs = {t["slug"] for t in themes}
orphans = [
d for d in sorted(ROOT.iterdir())
if d.is_dir() and not d.name.startswith("_") and d.name not in config_slugs
]
if orphans:
print("\nOrphaned directories (no matching config):")
for d in orphans:
print(f" {d.name}/")
answer = input("Remove these directories? [y/N] ").strip().lower()
if answer == "y":
for d in orphans:
shutil.rmtree(d)
print(f" Removed {d.name}/")
else:
print(" Skipped orphan removal.")
update_gitattributes()
print(f"\nSynced {len(themes)} theme directories.")
# ---------------------------------------------------------------------------
# Check
# ---------------------------------------------------------------------------
def cmd_check():
themes = discover_themes()
drifted = []
missing = []
for theme in themes:
slug = theme["slug"]
title = theme["title"]
identifier = slug.replace("-", "_")
theme_dir = ROOT / slug
# .streamlit/config.toml
target = theme_dir / ".streamlit" / "config.toml"
expected = expected_config(slug)
if not target.exists():
missing.append(f"{slug}/.streamlit/config.toml")
elif target.read_text() != expected:
drifted.append(f"{slug}/.streamlit/config.toml")
# streamlit_app.py
target = theme_dir / "streamlit_app.py"
if not target.exists():
missing.append(f"{slug}/streamlit_app.py")
elif target.read_text() != expected_app(title):
drifted.append(f"{slug}/streamlit_app.py")
# pyproject.toml
target = theme_dir / "pyproject.toml"
expected = expected_from_template(
TEMPLATES / "pyproject.toml.tmpl",
{"slug": slug, "title": title},
)
if not target.exists():
missing.append(f"{slug}/pyproject.toml")
elif target.read_text() != expected:
drifted.append(f"{slug}/pyproject.toml")
# snowflake.yml
target = theme_dir / "snowflake.yml"
expected = expected_from_template(
TEMPLATES / "snowflake.yml.tmpl",
{"slug": slug, "title": title, "identifier": identifier},
)
if not target.exists():
missing.append(f"{slug}/snowflake.yml")
elif target.read_text() != expected:
drifted.append(f"{slug}/snowflake.yml")
# Fonts
config_text = (CONFIGS / f"{slug}.toml").read_text()
font_names = discover_fonts(config_text)
for fname in font_names:
src = FONTS / fname
dest = theme_dir / "static" / fname
if not dest.exists():
missing.append(f"{slug}/static/{fname}")
elif src.exists() and src.read_bytes() != dest.read_bytes():
drifted.append(f"{slug}/static/{fname}")
ok = True
if missing:
print("Missing files (run 'python manage.py sync' to fix):")
for f in missing:
print(f" {f}")
ok = False
if drifted:
print("Drifted files (run 'python manage.py sync' to fix):")
for f in drifted:
print(f" {f}")
ok = False
if ok:
print(f"All generated files in sync across {len(themes)} theme directories.")
else:
sys.exit(1)
# ---------------------------------------------------------------------------
# New
# ---------------------------------------------------------------------------
def cmd_new(name):
config_path = CONFIGS / f"{name}.toml"
if config_path.exists():
print(f"Error: _configs/{name}.toml already exists", file=sys.stderr)
sys.exit(1)
config_path.write_text(
f"[server]\nenableStaticServing = true\n\n"
f"# {name} theme\n[theme]\nbase = \"dark\"\n"
)
print(f"Created _configs/{name}.toml — edit it, then run 'python manage.py sync'")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print(__doc__.strip())
sys.exit(0 if len(sys.argv) > 1 else 1)
if not SHARED.is_dir():
print(f"Error: {SHARED} not found", file=sys.stderr)
sys.exit(1)
if not CONFIGS.is_dir():
print(f"Error: {CONFIGS} not found", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "sync":
cmd_sync()
elif cmd == "new":
if len(sys.argv) < 3:
print("Usage: python manage.py new NAME", file=sys.stderr)
sys.exit(1)
cmd_new(sys.argv[2])
elif cmd == "check":
cmd_check()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,10 @@
[project]
name = "theme-minimal"
version = "1.0.0"
description = "Minimal theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Minimal**")
@@ -0,0 +1,10 @@
[project]
name = "theme-nord"
version = "1.0.0"
description = "Nord theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Nord**")
@@ -0,0 +1,10 @@
[project]
name = "theme-snowflake"
version = "1.0.0"
description = "Snowflake theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Snowflake**")
@@ -0,0 +1,10 @@
[project]
name = "theme-solarized-light"
version = "1.0.0"
description = "Solarized Light theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Solarized Light**")
@@ -0,0 +1,10 @@
[project]
name = "theme-spotify"
version = "1.0.0"
description = "Spotify theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Spotify**")
@@ -0,0 +1,10 @@
[project]
name = "theme-stripe"
version = "1.0.0"
description = "Stripe theme for Streamlit"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
"pandas>=2.2.3",
"streamlit>=1.53.0",
]
@@ -0,0 +1,337 @@
"""
Streamlit Element Explorer - Theme Demo
A comprehensive single-page app showcasing all major Streamlit components
with custom theming. Use this to preview how your theme looks across
different element types.
"""
# DO NOT EDIT — managed by manage.py, edit _shared/streamlit_app.py instead
import numpy as np
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Element Explorer", page_icon="🎨", layout="wide")
# Initialize sample data in session state
if "chart_data" not in st.session_state:
np.random.seed(42)
st.session_state.chart_data = pd.DataFrame(
np.random.randn(20, 3), columns=["a", "b", "c"]
)
chart_data = st.session_state.chart_data
st.title("Streamlit Element Explorer")
st.markdown(
"Explore how Streamlit's built-in elements look with this theme. "
"Select a category below to preview different components."
)
# Navigation using segmented_control for better performance
section = st.segmented_control(
"Section",
["Widgets", "Data", "Charts", "Text", "Layouts", "Chat", "Status"],
default="Widgets",
label_visibility="collapsed",
)
st.divider()
# -----------------------------------------------------------------------------
# WIDGETS SECTION
# -----------------------------------------------------------------------------
if section == "Widgets":
st.header("Widgets")
# Buttons
st.subheader("Buttons")
cols = st.columns(4)
cols[0].button("Primary", type="primary")
cols[1].button("Secondary", type="secondary")
cols[2].button("Tertiary", type="tertiary")
cols[3].link_button("Link", url="https://streamlit.io", icon=":material/open_in_new:")
# Form
with st.form(key="demo_form"):
st.subheader("Form")
form_cols = st.columns(2)
form_cols[0].text_input("Name", placeholder="Enter your name")
form_cols[1].text_input("Email", placeholder="you@example.com")
st.form_submit_button("Submit", type="primary")
# Selection widgets
st.subheader("Selection Widgets")
sel_cols = st.columns(2)
with sel_cols[0]:
st.checkbox("Checkbox option")
st.toggle("Toggle switch")
st.selectbox("Selectbox", options=["Option A", "Option B", "Option C"])
st.multiselect("Multiselect", options=["Tag 1", "Tag 2", "Tag 3"], default=["Tag 1"])
with sel_cols[1]:
st.radio("Radio buttons", options=["Choice 1", "Choice 2", "Choice 3"], horizontal=True)
st.pills("Pills", options=["Small", "Medium", "Large"], default="Medium")
st.segmented_control("Segmented", options=["Day", "Week", "Month"], default="Week")
st.caption("Feedback widget")
st.feedback("stars")
# Numeric & Sliders
st.subheader("Numeric Inputs")
num_cols = st.columns(3)
num_cols[0].number_input("Number input", value=42)
num_cols[1].slider("Slider", 0, 100, 50)
num_cols[2].select_slider("Select slider", options=["XS", "S", "M", "L", "XL"], value="M")
# Date/Time
st.subheader("Date & Time")
dt_cols = st.columns(2)
dt_cols[0].date_input("Date input")
dt_cols[1].time_input("Time input")
# Text inputs
st.subheader("Text Inputs")
txt_cols = st.columns(2)
txt_cols[0].text_input("Text input", placeholder="Type something...")
txt_cols[1].text_area("Text area", placeholder="Longer text goes here...", height=100)
# File upload
st.subheader("File Upload")
st.file_uploader("Upload a file", type=["csv", "txt", "pdf"])
# -----------------------------------------------------------------------------
# DATA SECTION
# -----------------------------------------------------------------------------
elif section == "Data":
st.header("Data Display")
# Metrics
st.subheader("Metrics")
m_cols = st.columns(4)
m_cols[0].metric("Revenue", "$45,231", "+12.5%")
m_cols[1].metric("Users", "2,847", "+8.2%")
m_cols[2].metric("Conversion", "3.24%", "-0.4%", delta_color="inverse")
m_cols[3].metric("Avg. Session", "4m 32s", "+1.2%")
st.divider()
# Dataframe
st.subheader("Dataframe")
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"Department": ["Engineering", "Sales", "Marketing", "Engineering", "Sales"],
"Salary": [95000, 78000, 82000, 105000, 71000],
"Start Date": pd.date_range("2022-01-15", periods=5, freq="3M"),
"Active": [True, True, False, True, True],
})
st.dataframe(
df,
hide_index=True,
column_config={
"Salary": st.column_config.NumberColumn(format="$%d"),
"Start Date": st.column_config.DateColumn(format="MMM DD, YYYY"),
"Active": st.column_config.CheckboxColumn("Active?"),
},
)
# Table
st.subheader("Static Table")
st.table(chart_data.head(5))
# JSON
st.subheader("JSON Display")
st.json({"name": "Streamlit", "version": "1.41.0", "features": ["themes", "widgets", "charts"]})
# -----------------------------------------------------------------------------
# CHARTS SECTION
# -----------------------------------------------------------------------------
elif section == "Charts":
st.header("Charts")
chart_cols = st.columns(2)
with chart_cols[0]:
st.subheader("Line Chart")
st.line_chart(chart_data, height=250)
st.subheader("Bar Chart")
st.bar_chart(chart_data, height=250)
with chart_cols[1]:
st.subheader("Area Chart")
st.area_chart(chart_data, height=250)
st.subheader("Scatter Chart")
st.scatter_chart(chart_data, height=250)
# -----------------------------------------------------------------------------
# TEXT SECTION
# -----------------------------------------------------------------------------
elif section == "Text":
st.header("Text Elements")
# Headers
st.subheader("Headers")
st.title("Title Element")
st.header("Header Element")
st.subheader("Subheader Element")
st.caption("Caption text - smaller, muted")
st.divider()
# Markdown
st.subheader("Markdown Formatting")
st.markdown(
"**Bold text**, *italic text*, ~~strikethrough~~, "
"`inline code`, [link](https://streamlit.io)"
)
st.markdown("Math: $E = mc^2$ and $\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$")
st.markdown("Emojis: 🚀 🎨 📊 ✨ and icons: :material/home: :material/settings:")
# Colored text
st.subheader("Colored Text")
color_cols = st.columns(3)
color_cols[0].markdown(":red[Red text] and :orange[Orange text]")
color_cols[1].markdown(":green[Green text] and :blue[Blue text]")
color_cols[2].markdown(":violet[Violet text] and :rainbow[Rainbow text]")
# Code blocks
st.subheader("Code Block")
st.code(
'''import streamlit as st
# Create a themed dashboard
st.set_page_config(page_title="My App", layout="wide")
st.title("Hello, Streamlit!")
# Display metrics
col1, col2 = st.columns(2)
col1.metric("Users", "1,234", "+5%")
col2.metric("Revenue", "$56K", "+12%")''',
language="python",
)
# -----------------------------------------------------------------------------
# LAYOUTS SECTION
# -----------------------------------------------------------------------------
elif section == "Layouts":
st.header("Layout Elements")
# Columns
st.subheader("Columns with Borders")
layout_cols = st.columns(3, border=True)
layout_cols[0].write("**Column 1**\n\nFirst column content")
layout_cols[1].write("**Column 2**\n\nSecond column content")
layout_cols[2].write("**Column 3**\n\nThird column content")
# Tabs
st.subheader("Tabs")
tab1, tab2, tab3 = st.tabs(["📈 Chart", "📋 Data", "⚙️ Settings"])
with tab1:
st.write("Chart tab content")
st.line_chart(chart_data["a"], height=150)
with tab2:
st.write("Data tab content")
st.dataframe(chart_data.head(3))
with tab3:
st.write("Settings tab content")
st.checkbox("Enable feature X")
st.checkbox("Enable feature Y", value=True)
# Expander
st.subheader("Expander")
with st.expander("Click to expand"):
st.write("This content is hidden by default.")
st.image("https://placehold.co/400x200/29B5E8/white?text=Expanded+Content")
# Popover
st.subheader("Popover")
pop_cols = st.columns(3)
with pop_cols[0].popover("Open popover", icon=":material/info:"):
st.write("Popover content here!")
st.slider("Popover slider", 0, 100, 50)
# Container
st.subheader("Container with Border")
with st.container(border=True):
st.write("**Bordered Container**")
st.write("Content inside a container with a visible border.")
st.button("Button inside container")
# -----------------------------------------------------------------------------
# CHAT SECTION
# -----------------------------------------------------------------------------
elif section == "Chat":
st.header("Chat Elements")
# Chat messages
st.subheader("Chat Messages")
with st.chat_message("user"):
st.write("Hello! How can I analyze my sales data?")
with st.chat_message("assistant"):
st.write("I can help you with that! Here are a few options:")
st.markdown("""
1. **Revenue trends** - View monthly/quarterly patterns
2. **Top products** - Identify best sellers
3. **Customer segments** - Analyze by region or category
""")
with st.chat_message("user"):
st.write("Show me the revenue trends please.")
with st.chat_message("assistant"):
st.write("Here's your revenue trend for the past 20 periods:")
st.line_chart(chart_data["a"], height=200)
# Chat input
st.chat_input("Type a message...")
# -----------------------------------------------------------------------------
# STATUS SECTION
# -----------------------------------------------------------------------------
elif section == "Status":
st.header("Status Elements")
# Alert messages
st.subheader("Alert Messages")
st.error("Error: Something went wrong with the data pipeline.")
st.warning("Warning: API rate limit approaching (80% used).")
st.info("Info: New features available in the latest release.")
st.success("Success: Data exported successfully to warehouse.")
# Exception
st.subheader("Exception Display")
try:
raise ValueError("This is an example exception for demonstration")
except ValueError as e:
st.exception(e)
# Interactive status
st.subheader("Interactive Status")
status_cols = st.columns(3)
if status_cols[0].button("Show Toast", icon=":material/notifications:"):
st.toast("This is a toast notification!", icon="🔔")
if status_cols[1].button("Balloons", icon=":material/celebration:"):
st.balloons()
if status_cols[2].button("Snow", icon=":material/ac_unit:"):
st.snow()
# Progress
st.subheader("Progress Indicators")
st.progress(0.7, text="70% complete")
with st.spinner("Loading..."):
st.write("Spinner is active (non-blocking in this demo)")
# -----------------------------------------------------------------------------
# SIDEBAR
# -----------------------------------------------------------------------------
with st.sidebar:
st.header("Settings")
st.selectbox("Time Period", ["Last 7 days", "Last 30 days", "Last 90 days", "All time"])
st.multiselect("Metrics", ["Revenue", "Users", "Sessions"], default=["Revenue", "Users"])
st.slider("Confidence threshold", 0.0, 1.0, 0.8)
st.divider()
st.caption("Element Explorer v1.0")
st.caption("Theme: **Stripe**")
+63
View File
@@ -0,0 +1,63 @@
# ── Python ──────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
*.egg
.eggs/
# ── Virtual environments ───────────────────────────────
.venv/
venv/
env/
ENV/
# ── Build / Distribution ──────────────────────────────
dist/
build/
*.whl
*.tar.gz
# ── Testing / Coverage ────────────────────────────────
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
# ── Linting / Type checking ──────────────────────────
.mypy_cache/
.ruff_cache/
.pytype/
# ── Runtime data (user-specific, contains device IPs/configs) ─
data/
# ── AI conversation history ──────────────────────────
.specstory/
# ── IDE / Editor ─────────────────────────────────────
.idea/
.vscode/
*.swp
*.swo
*~
# ── OS ───────────────────────────────────────────────
.DS_Store
Thumbs.db
# ── Secrets / Credentials (preventive) ──────────────
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
secrets.json
# ── Logs ─────────────────────────────────────────────
*.log
+1
View File
@@ -0,0 +1 @@
3.12
+12
View File
@@ -0,0 +1,12 @@
[theme]
base = "dark"
primaryColor = "#4dabf7"
backgroundColor = "#0d1117"
secondaryBackgroundColor = "#161b22"
textColor = "#c9d1d9"
font = "sans serif"
[server]
headless = true
# Rerun the app automatically when Python sources change (same as `shelly-manager-ui` defaults).
runOnSave = true
+88
View File
@@ -0,0 +1,88 @@
# Shelly Manager
Manage Shelly Gen1 and Gen2+ devices on your LAN: discovery (mDNS + subnet scan), inventory, configuration, and mass operations.
## Requirements
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) recommended
## Install
```bash
cd shelly-ui
uv sync
```
## Tests
```bash
uv sync
uv run pytest
```
Pytest is configured to disable the `unraisableexception` plugin: Streamlits `AppTest` runs pages that call `asyncio.run()`, and teardown can emit `ResourceWarning` for sockets/event loops that would otherwise make pytest exit with an `ExceptionGroup` even when all tests pass.
- **Unit / async tests**: models; subnet scan (`fetch_shelly_json` mocked); **discovery helpers** (IPv6 URL host brackets, `probe_ip` port); SQLite + Markdown storage; `DeviceManager` (mocked mDNS + enrich). Real mDNS/zeroconf is not run in CI (no devices/network dependency).
- **Streamlit UI tests**: [`streamlit.testing.v1.AppTest`](https://docs.streamlit.io/develop/api-reference/app-testing) runs the home page and multipage scripts in-process — **no live browser or running `streamlit run` required**.
To add **browser E2E** tests against your local UI (`http://localhost:8501`), use Playwright or Selenium in a separate optional suite; `AppTest` already covers UI structure without flakiness from a real server.
## CLI (Textual TUI)
```bash
uv run shelly-manager
# or
uv run python -m shelly_manager.cli
```
Options:
```bash
uv run shelly-manager --help
uv run shelly-manager --storage sqlite --db-path ./data/devices.db
uv run shelly-manager --storage markdown --markdown-dir ./data/devices_md
```
The **Textual TUI** uses the same **`DeviceFilter`** as the web UI: generation, online/auth, combined name search, model & firmware substrings, IP prefix & MAC, comma-separated **tags** and **capabilities**, and dropdown **presets** for cached **settings** and **status** JSON. The Streamlit app adds **exact model multiselect**, **tag multiselect**, and **custom dot- or `|`-separated paths** for settings/status (use `|` when a key contains `:` e.g. `switch:0|output`).
## Web UI (Streamlit)
```bash
uv run shelly-manager-ui
# or
uv run streamlit run src/shelly_manager/ui/Dashboard.py
```
**Hot reload:** `shelly-manager-ui` enables Streamlits **Run on save** (`server.runOnSave`), so edits to the app or imported modules trigger an automatic rerun. To disable (manual “Rerun” only), set `SHELLY_UI_NO_RELOAD=1`. Project [`.streamlit/config.toml`](.streamlit/config.toml) also sets `runOnSave` when you use `streamlit run` from the repo root.
**Settings persistence:** The **Settings** page writes **`data/shelly_ui_config.json`** (by default, relative to the process working directory — run from the repo root so `data/` is stable). Override the path with env **`SHELLY_UI_CONFIG`**. Without this file, settings lived only in browser session state and were lost on restart.
**Text fields:** Single-line inputs use a **✕** control on the right of the input row to clear the field in one click (Streamlit does not render a native in-field clear icon; multi-line JSON fields show **✕** on the top-right of the block). Implementation: `shelly_manager.ui.components.clearable_input`.
Each device shows an **Open device web UI** link using the devices HTTP URL (`http://…/`), with correct bracketing for IPv6. Non-default ports from mDNS discovery are stored on the device as `http_port`.
**Discovery:** The app probes **mDNS** (`_shelly._tcp` / `_http._tcp`) and an optional **subnet CIDR** (`GET http://IP/shelly` on each address — use `192.168.x.0/24` for a whole LAN, or `192.168.x.y/32` for one IP). To add a **single device** by address, use **Dashboard → Add Shelly device manually** (IP only). Each discovery run uses the **latest saved settings** (`get_config()`), not only the in-memory `DeviceManager` snapshot. **Dashboard → Refresh inventory from network** only re-fetches devices **already in inventory**; it does **not** scan the subnet and ignores Subnet CIDR for finding new addresses. **Discovery details** on the Dashboard shows a **text log** (devices found + source) and JSON stats. The Python logger **`shelly_manager.core.device_manager`** also emits **INFO** lines per device. Devices that require login may not expose `/shelly` without auth.
The **Dashboard** page (`Dashboard.py`) is the main inventory: an **Inventory overview** with metrics (totals, online/offline, Gen1/2/3, auth, reboot, firmware-check buckets, tags) plus bar charts for generation and firmware status, **top models** and **capabilities** tables. **Click the numbers** in the overview to set URL query parameters (`?preset=…`, or `?preset=model&model=…` / `?cap=…` for model/cap rows) so **Filters** match that slice (applied once, then cleared from the URL). Then discovery, **Add Shelly device manually** (single IP), the same **inline filters** as Mass Config (including **Tag filter**: any / has tags / untagged), **visible columns** (shared preference), and a read-only table (including **Needs reboot** from `Shelly.GetStatus``sys.restart_required` after a live refresh, plus **Reboot filtered devices that need reboot**) (device **name** / **IP** links, **Config snapshots** column with the last *N* stored GetConfig snapshot labels per device — *N* is **Settings → Config snapshots shown per device**). **FW update** uses **`Shelly.CheckForUpdate`** when you run **Check firmware**, and otherwise falls back to **`Shelly.GetStatus``sys.available_updates`** (same information the device web UI uses; updated periodically on the device). Stored CheckForUpdate results are **kept** across **refresh** (they used to be cleared). Use **Check firmware** or **Refresh** so the column stays current. Filter **FW update = Has stable update** (default “offered” meaning) for devices with a **stable** channel build; use **Beta only** or **Stable or beta** to include beta-only offers. Additional pages: **Device**, **Mass Config**, **Settings** (see `src/shelly_manager/ui/pages/`). Dark theme: [`.streamlit/config.toml`](.streamlit/config.toml) ([Streamlit theming](https://docs.streamlit.io/develop/concepts/configuration/theming)).
**Device page:** **Firmware** section shows the installed version, runs **`Shelly.CheckForUpdate`**, and can start **`Shelly.Update`** OTA (**stable** or **beta**) on Gen2+ without auth (device must reach the internet; the device reboots when the install finishes). Builds a **dynamic editor** from the last **`Shelly.GetConfig`** snapshot (one expander per top-level key: `sys`, `wifi`, `switch:0`, …). Edit scalars and nested objects in the form; **lists** are edited as JSON. **Save to device** (Gen2+) sends each **changed** section via `Component.SetConfig` RPC; then the inventory is refreshed. The **Last config apply** expander lists each section with a **Restart required** column when the RPC response asks for a reboot; you can **Reboot device now** from the UI. **Gen1** only saves to **local inventory** (apply on the device separately). **Discard** resets the form widgets. Authenticated-only devices show raw JSON until credentials are supported. From the Dashboard or Mass Config inventory table, **click the device name** to open the in-app Device page, or **click the IP** to open the devices **`http://` web UI** (typically in a new tab). You can also open a device via **`?device=<id>`** in the URL. **Configuration history** compares stored **GetConfig** snapshots as a unified diff (snapshot count per device is capped in **Settings**).
**Mass configuration:** Table-first: **filters** narrow *which devices appear* (same layout as the **Dashboard**). **Include** checkboxes + **Select / deselect all** choose *which filtered rows* receive an action — e.g. filter **Needs reboot = yes**, include those rows, then **Bulk actions → Device control → Reboot devices**. **Action category** groups **Device control** (reboot, identify), **Diagnostics** (live refresh, firmware check), and **RPC configuration** (BLE / MQTT / Cloud). **Custom section config** lets you paste a JSON object for **one** top-level GetConfig key (`mqtt`, `wifi`, `coiot`, `sntp`, `sys`, …) with **merge** or **replace**, for advanced bulk edits (same `Component.SetConfig` path as the Device page). **Tags** apply to selected rows only. **Latest bulk operation results** stays until dismissed. **Refresh table** live-refreshes listed devices. **Settings → Mass Config: refresh table after bulk operation** still applies after bulk runs. **Name** / **IP** are links. Gen2+-only actions skip Gen1 where appropriate.
## Project layout
- `shelly_manager.core` — models, discovery, device manager
- `shelly_manager.api` — aioshelly-based Gen1/Gen2 clients
- `shelly_manager.storage` — SQLite or Markdown persistence
- `shelly_manager.cli` — Textual app
- `shelly_manager.ui` — Streamlit multipage app
## Docs
- [Shelly Gen1 API](https://shelly-api-docs.shelly.cloud/gen1/)
- [Shelly Gen2+ API](https://shelly-api-docs.shelly.cloud/gen2/)
## Note
Authentication on devices is not implemented in this version; unauthenticated devices are supported.
+64
View File
@@ -0,0 +1,64 @@
[project]
name = "shelly-manager"
version = "0.1.0"
description = "Shelly device management: shared core, Textual CLI, Streamlit UI"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"aioshelly>=13.0.0",
"aiohttp>=3.9.0",
"aiosqlite>=0.20.0",
"pydantic>=2.5.0",
"zeroconf>=0.132.0",
"textual>=0.86.0",
"rich>=13.7.0",
"streamlit>=1.40.0",
"typer>=0.12.0",
"pyyaml>=6.0.0",
"watchdog>=6.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.8.0",
]
[project.scripts]
shelly-manager = "shelly_manager.cli.app:app_cli"
shelly-manager-ui = "shelly_manager.ui.entry:main"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = [
"integration: real LAN / Shelly devices (enable with SHELLY_INTEGRATION=1)",
]
# Streamlit AppTest + asyncio.run in page scripts leave sockets/event loops that GC
# clears after tests; pytest's unraisableexception plugin then raises ExceptionGroup and
# exit code 1 even when all tests passed. Disable that plugin for this project.
addopts = "-p no:unraisableexception"
# Also quiet ResourceWarning text on stderr for the same teardown noise.
filterwarnings = [
"ignore:unclosed event loop:ResourceWarning",
"ignore:unclosed <socket:ResourceWarning",
]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
]
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"developing-with-streamlit": {
"source": "streamlit/agent-skills",
"sourceType": "github",
"computedHash": "d366960761de1ec907dee42ddbdc08b9c868584cebc8c9fe8e0efdccb4bc5db7"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
"""Shelly Manager — shared core, CLI, and Streamlit UI."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from shelly_manager.api import gen1, gen2
from shelly_manager.api.client import fetch_device_snapshot, probe_ip
from shelly_manager.api.context import ShellyRuntime
__all__ = ["ShellyRuntime", "fetch_device_snapshot", "probe_ip", "gen1", "gen2"]
+241
View File
@@ -0,0 +1,241 @@
"""Connect to Shelly devices via aioshelly and map to unified models."""
from __future__ import annotations
import copy
import logging
from typing import Any
import aiohttp
from aiohttp import BasicAuth
from aioshelly.block_device.device import BlockDevice
from aioshelly.common import ConnectionOptions
from aioshelly.exceptions import ShellyError
from aioshelly.rpc_device.device import RpcDevice
from shelly_manager.api.context import ShellyRuntime
from shelly_manager.core.discovery import classify_generation, fetch_shelly_json, normalize_mac
from shelly_manager.core.models import ShellyDevice, infer_capabilities
_LOGGER = logging.getLogger(__name__)
def _deepcopy_json(d: dict[str, Any]) -> dict[str, Any]:
return copy.deepcopy(d)
def block_device_to_model(dev: BlockDevice) -> ShellyDevice:
"""Map initialized BlockDevice to ShellyDevice."""
mac = str(dev.shelly.get("mac", ""))
gen = 1
caps = infer_capabilities(gen, _deepcopy_json(dev.status), _deepcopy_json(dev.settings))
return ShellyDevice(
id=normalize_mac(mac),
name=dev.name,
mac=mac,
ip=dev.ip_address,
generation=gen,
model=dev.model,
firmware=dev.firmware_version,
online=True,
capabilities=caps,
status=_deepcopy_json(dev.status),
settings=_deepcopy_json(dev.settings),
auth_required=bool(dev.shelly.get("auth")),
)
def rpc_device_to_model(dev: RpcDevice) -> ShellyDevice:
"""Map initialized RpcDevice to ShellyDevice."""
shelly = dev.shelly
mac = str(shelly.get("mac", ""))
gen = int(shelly.get("gen", 2))
gen = max(2, min(gen, 3))
caps = infer_capabilities(
gen,
_deepcopy_json(dev.status),
_deepcopy_json(dev.config),
)
return ShellyDevice(
id=normalize_mac(mac),
name=dev.name,
mac=mac,
ip=dev.ip_address,
generation=gen, # type: ignore[arg-type]
model=dev.model,
firmware=dev.firmware_version,
online=True,
capabilities=caps,
status=_deepcopy_json(dev.status),
settings=_deepcopy_json(dev.config),
auth_required=bool(shelly.get("auth_en")),
)
async def fetch_device_snapshot(runtime: ShellyRuntime, ip: str, generation: int) -> ShellyDevice:
"""
Connect, initialize, snapshot, shutdown — one-shot fetch.
Raises ShellyError subclasses on failure.
"""
if runtime.session is None or runtime.coap is None:
raise RuntimeError("ShellyRuntime not entered")
session = runtime.session
if generation >= 2:
dev = await RpcDevice.create(session, None, ip)
await dev.initialize()
try:
return rpc_device_to_model(dev)
finally:
await dev.shutdown()
dev = await BlockDevice.create(session, runtime.coap, ip)
await dev.initialize()
try:
return block_device_to_model(dev)
finally:
await dev.shutdown()
async def probe_ip(
session: aiohttp.ClientSession,
ip: str,
port: int = 80,
*,
timeout_sec: float = 3.0,
) -> tuple[int, dict[str, Any]] | None:
"""GET /shelly and return (generation, json) or None."""
data = await fetch_shelly_json(session, ip, port=port, timeout_sec=timeout_sec)
if not data:
return None
return classify_generation(data), data
async def refresh_with_auth(
runtime: ShellyRuntime,
ip: str,
generation: int,
*,
username: str | None = None,
password: str | None = None,
) -> ShellyDevice:
"""Fetch snapshot with optional HTTP basic (Gen1) / digest prep (Gen2 via options)."""
if runtime.session is None or runtime.coap is None:
raise RuntimeError("ShellyRuntime not entered")
session = runtime.session
auth = None
if username and password:
auth = BasicAuth(username, password)
if generation >= 2:
opts = ConnectionOptions(ip_address=ip, username=username, password=password)
dev = await RpcDevice.create(session, None, opts)
await dev.initialize()
try:
return rpc_device_to_model(dev)
finally:
await dev.shutdown()
opts = ConnectionOptions(ip_address=ip, username=username, password=password)
dev = await BlockDevice.create(session, runtime.coap, opts)
await dev.initialize()
try:
return block_device_to_model(dev)
finally:
await dev.shutdown()
async def reboot_device(
runtime: ShellyRuntime,
ip: str,
generation: int,
*,
http_port: int = 80,
) -> None:
"""Request a device reboot (Gen2+ WebSocket RPC or Gen1 HTTP)."""
if runtime.session is None or runtime.coap is None:
raise RuntimeError("ShellyRuntime not entered")
if generation >= 2:
port = int(http_port or 80)
opts = ConnectionOptions(ip_address=ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
await dev.trigger_reboot()
finally:
await dev.shutdown()
else:
dev = await BlockDevice.create(runtime.session, runtime.coap, ip)
await dev.initialize()
try:
await dev.trigger_reboot()
finally:
await dev.shutdown()
async def check_for_update(
runtime: ShellyRuntime,
ip: str,
generation: int,
*,
http_port: int = 80,
) -> dict[str, Any]:
"""Call ``Shelly.CheckForUpdate``. Empty result means no firmware update is available (Gen2+)."""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
if generation < 2:
return {}
port = int(http_port or 80)
opts = ConnectionOptions(ip_address=ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
return await dev.call_rpc("Shelly.CheckForUpdate")
finally:
await dev.shutdown()
async def trigger_firmware_update(
runtime: ShellyRuntime,
ip: str,
generation: int,
*,
http_port: int = 80,
beta: bool = False,
) -> None:
"""Call ``Shelly.Update`` (OTA). Device typically reboots when the install finishes."""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
if generation < 2:
raise ValueError("Firmware update over RPC requires Gen2+ (Gen1: use the device web UI).")
port = int(http_port or 80)
opts = ConnectionOptions(ip_address=ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
await dev.trigger_ota_update(beta=beta)
finally:
await dev.shutdown()
async def identify_device(
runtime: ShellyRuntime,
ip: str,
generation: int,
*,
http_port: int = 80,
) -> None:
"""Flash LEDs / identify — Gen2+ ``Shelly.Identify`` (uses ``http_port``)."""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
if generation < 2:
raise ValueError("Shelly.Identify requires Gen2+")
port = int(http_port or 80)
opts = ConnectionOptions(ip_address=ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
await dev.call_rpc("Shelly.Identify")
finally:
await dev.shutdown()
+28
View File
@@ -0,0 +1,28 @@
"""Shared aiohttp session + Gen1 CoAP context."""
from __future__ import annotations
import aiohttp
from aioshelly.block_device.coap import COAP
class ShellyRuntime:
"""Lifecycle for aioshelly BlockDevice (CoAP) + shared HTTP session."""
def __init__(self) -> None:
self.session: aiohttp.ClientSession | None = None
self.coap: COAP | None = None
async def __aenter__(self) -> ShellyRuntime:
self.session = aiohttp.ClientSession()
self.coap = COAP()
await self.coap.initialize()
return self
async def __aexit__(self, *args: object) -> None:
if self.coap is not None:
self.coap.close()
self.coap = None
if self.session is not None:
await self.session.close()
self.session = None
+6
View File
@@ -0,0 +1,6 @@
"""Gen1 (CoAP/HTTP) helpers — re-exports from the unified client."""
from shelly_manager.api.client import block_device_to_model, fetch_device_snapshot
from shelly_manager.api.context import ShellyRuntime
__all__ = ["ShellyRuntime", "block_device_to_model", "fetch_device_snapshot"]
+6
View File
@@ -0,0 +1,6 @@
"""Gen2+ RPC helpers — re-exports from the unified client."""
from shelly_manager.api.client import fetch_device_snapshot, rpc_device_to_model
from shelly_manager.api.context import ShellyRuntime
__all__ = ["ShellyRuntime", "rpc_device_to_model", "fetch_device_snapshot"]
+152
View File
@@ -0,0 +1,152 @@
"""Apply Shelly Gen2+ GetConfig-shaped updates via per-component *.SetConfig RPC calls."""
from __future__ import annotations
import json
import logging
from typing import Any
from aioshelly.common import ConnectionOptions
from aioshelly.exceptions import RpcCallError, ShellyError
from aioshelly.rpc_device.device import RpcDevice
from shelly_manager.api.context import ShellyRuntime
_LOGGER = logging.getLogger(__name__)
def _deep_equal(a: Any, b: Any) -> bool:
try:
return json.dumps(a, sort_keys=True, default=str) == json.dumps(
b, sort_keys=True, default=str
)
except (TypeError, ValueError):
return a == b
def _component_class_name(segment: str) -> str:
"""Map first segment of a GetConfig key to RPC component class name."""
s = segment.strip().lower()
overrides: dict[str, str] = {
"wifi": "WiFi",
"ws": "Ws",
"ble": "BLE",
"mqtt": "MQTT",
"cloud": "Cloud",
"sys": "Sys",
"eth": "Eth",
"em": "EM",
"em1": "EM1",
"em1data": "EM1Data",
"emdata": "EMData",
"humidity": "Humidity",
"temperature": "Temperature",
"input": "Input",
"switch": "Switch",
"cover": "Cover",
"light": "Light",
"script": "Script",
"thermostat": "Thermostat",
"rgbw": "RGBW",
"rgb": "RGB",
"plugsui": "PlugsUI",
"enum": "Enum",
"number": "Number",
"text": "Text",
"boolean": "Boolean",
"pm1": "PM1",
"virtual": "Virtual",
}
if s in overrides:
return overrides[s]
if not segment:
return segment
return segment[0].upper() + segment[1:]
def _parse_top_level_key(key: str) -> tuple[str, int | None]:
"""Return (component_segment, id_or_none). Keys look like `switch:0` or `sys`."""
if ":" in key:
comp, rest = key.split(":", 1)
try:
return comp, int(rest)
except ValueError:
return key, None
return key, None
def set_config_method_and_params(key: str, config: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Build RPC method name and params for Shelly.GetConfig top-level key."""
comp, idx = _parse_top_level_key(key)
cls = _component_class_name(comp)
method = f"{cls}.SetConfig"
params: dict[str, Any] = {"config": config}
if idx is not None:
params["id"] = idx
return method, params
def iter_changed_top_level_keys(
old_cfg: dict[str, Any],
new_cfg: dict[str, Any],
) -> list[str]:
"""Top-level GetConfig keys whose value differs (JSON-normalized)."""
keys: list[str] = []
for key in sorted(set(old_cfg.keys()) | set(new_cfg.keys())):
if key not in new_cfg:
continue
old_v = old_cfg.get(key)
new_v = new_cfg[key]
if _deep_equal(old_v, new_v):
continue
keys.append(key)
return keys
async def apply_gen2_config_diff(
runtime: ShellyRuntime,
ip: str,
old_cfg: dict[str, Any],
new_cfg: dict[str, Any],
*,
port: int = 80,
) -> list[tuple[str, str, bool]]:
"""
For each top-level key whose value changed, call Component.SetConfig.
Returns list of ``(key, message, restart_required)`` where *message* is ``\"ok\"`` or an
error description. *restart_required* is taken from the RPC response when successful.
"""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
opts = ConnectionOptions(ip_address=ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
results: list[tuple[str, str, bool]] = []
try:
for key in iter_changed_top_level_keys(old_cfg, new_cfg):
new_v = new_cfg[key]
if not isinstance(new_v, dict):
results.append(
(
key,
f"skip: top-level value must be object, got {type(new_v).__name__}",
False,
)
)
continue
method, params = set_config_method_and_params(key, new_v)
try:
resp = await dev.call_rpc(method, params)
rr = bool(resp.get("restart_required")) if isinstance(resp, dict) else False
results.append((key, "ok", rr))
except RpcCallError as err:
_LOGGER.warning("RPC %s %s: %s", method, key, err)
results.append((key, f"RPC {err.code}: {err.message or str(err)}", False))
except ShellyError as err:
_LOGGER.warning("RPC %s %s: %s", method, key, err)
results.append((key, str(err), False))
return results
finally:
await dev.shutdown()
+318
View File
@@ -0,0 +1,318 @@
"""Apply mass configuration operations via aioshelly (Gen2+ RPC)."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from aioshelly.common import ConnectionOptions
from aioshelly.exceptions import RpcCallError, ShellyError
from aioshelly.rpc_device.device import RpcDevice
from pydantic import BaseModel
from shelly_manager.api.client import fetch_device_snapshot
from shelly_manager.api.context import ShellyRuntime
from shelly_manager.core.mass_config import get_mass_operation
from shelly_manager.core.models import ShellyDevice, preserve_firmware_check_metadata
_LOGGER = logging.getLogger(__name__)
RPC_METHOD_NOT_FOUND = -114
def _rpc_error_detail(err: RpcCallError) -> str:
"""Human-readable RPC failure for UI and logs."""
msg = (err.message or "").strip() or str(err)
return f"RPC {err.code}: {msg}"
class MassApplyResult(BaseModel):
"""Result of applying one mass-config operation to one device."""
device_id: str
display_name: str
status: str # ok | skipped | error
detail: str = ""
restart_required: bool = False
async def _with_rpc(
runtime: ShellyRuntime,
device: ShellyDevice,
fn: Callable[[RpcDevice], Awaitable[MassApplyResult]],
) -> MassApplyResult:
"""Run async callable(dev: RpcDevice) with a connected device (respects ``http_port``)."""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
port = int(device.http_port or 80)
opts = ConnectionOptions(ip_address=device.ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
return await fn(dev)
finally:
await dev.shutdown()
async def _apply_ble_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
out: dict | object
try:
out = await dev.ble_setconfig(False, False)
except RpcCallError as err:
if err.code == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="BLE.SetConfig not available on this model/firmware",
)
# Some firmware rejects nested ``rpc`` when disabling — try minimal payload.
try:
out = await dev.call_rpc("BLE.SetConfig", {"config": {"enable": False}})
except RpcCallError as err2:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=_rpc_error_detail(err2),
)
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="BLE.SetConfig disable",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_ble_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
out: dict | object
try:
out = await dev.ble_setconfig(True, True)
except RpcCallError as err:
if err.code == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="BLE.SetConfig not available on this model/firmware",
)
try:
out = await dev.call_rpc(
"BLE.SetConfig",
{"config": {"enable": True, "rpc": {"enable": True}}},
)
except RpcCallError as err2:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=_rpc_error_detail(err2),
)
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="BLE.SetConfig enable",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_mqtt_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": False}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="MQTT.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="MQTT.SetConfig enable=false",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_mqtt_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": True}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="MQTT.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="MQTT.SetConfig enable=true",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_cloud_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": False}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Cloud.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="Cloud.SetConfig enable=false",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_cloud_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": True}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Cloud.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="Cloud.SetConfig enable=true",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
_HANDLERS = {
"ble_disable": _apply_ble_disable,
"ble_enable": _apply_ble_enable,
"mqtt_disable": _apply_mqtt_disable,
"mqtt_enable": _apply_mqtt_enable,
"cloud_disable": _apply_cloud_disable,
"cloud_enable": _apply_cloud_enable,
}
async def apply_mass_operation(
runtime: ShellyRuntime,
device: ShellyDevice,
operation_id: str,
) -> MassApplyResult:
"""Apply a single **RPC config** operation to one device (caller persists refreshed snapshot)."""
meta = get_mass_operation(operation_id)
if not meta:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"Unknown operation {operation_id!r}",
)
if meta.kind != "rpc_config":
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"Operation {operation_id!r} is not an RPC config action — use DeviceManager.mass_apply_operation",
)
if device.generation not in meta.supported_generations:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail=f"Not supported for generation {device.generation}",
)
if device.auth_required:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Authentication required — configure credentials first",
)
handler = _HANDLERS.get(operation_id)
if not handler:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"No handler implemented for {operation_id!r}",
)
try:
return await handler(runtime, device)
except ShellyError as err:
_LOGGER.warning("mass apply %s on %s: %s", operation_id, device.id, err)
detail = _rpc_error_detail(err) if isinstance(err, RpcCallError) else str(err)
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=detail,
)
except Exception as err: # noqa: BLE001
_LOGGER.exception("mass apply %s on %s", operation_id, device.id)
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=str(err),
)
async def refresh_device_after_apply(
runtime: ShellyRuntime,
device: ShellyDevice,
) -> ShellyDevice | None:
"""Fetch fresh Shelly snapshot and preserve tags/notes."""
fresh = await fetch_device_snapshot(runtime, device.ip, device.generation)
fresh.tags = device.tags
fresh.notes = device.notes
fresh.http_port = device.http_port
preserve_firmware_check_metadata(fresh, device)
return fresh
+1
View File
@@ -0,0 +1 @@
"""Textual TUI for Shelly Manager."""
+4
View File
@@ -0,0 +1,4 @@
from shelly_manager.cli.app import main
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
"""Typer entry + Textual main loop."""
from __future__ import annotations
import asyncio
from pathlib import Path
import typer
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Vertical
from textual.widgets import Footer, Header, ListItem, ListView, Static
from shelly_manager.cli.screens.dashboard import DashboardScreen
from shelly_manager.cli.screens.discovery import DiscoveryScreen
from shelly_manager.cli.screens.mass_config import MassConfigScreen
from shelly_manager.core.config import AppConfig
from shelly_manager.core.device_manager import DeviceManager, storage_from_config
app_cli = typer.Typer(add_completion=False, no_args_is_help=True)
class MainMenu(App[None]):
"""Root menu to open sub-screens."""
BINDINGS = [Binding("q", "quit", "Quit"), Binding("escape", "quit", "Quit")]
def __init__(self, dm: DeviceManager) -> None:
super().__init__()
self.dm = dm
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container():
with Vertical(id="menu"):
yield Static("[bold]Shelly Manager[/]\n")
yield ListView(
ListItem(Static("Devices — inventory & refresh")),
ListItem(Static("Discover — mDNS + subnet scan")),
ListItem(Static("Mass tags — filter + apply tags")),
id="main_list",
)
yield Footer()
def on_list_view_selected(self, event: ListView.Selected) -> None:
idx = event.index
if idx == 0:
self.push_screen(DashboardScreen(self.dm))
elif idx == 1:
self.push_screen(DiscoveryScreen(self.dm))
elif idx == 2:
self.push_screen(MassConfigScreen(self.dm))
def action_quit(self) -> None:
self.exit()
def _build_config(
storage: str,
db_path: Path | None,
markdown_dir: Path | None,
subnet: str | None,
) -> AppConfig:
cfg = AppConfig(
storage_backend="sqlite" if storage == "sqlite" else "markdown",
subnet_scan_cidr=subnet,
)
if db_path is not None:
cfg.sqlite_path = db_path
if markdown_dir is not None:
cfg.markdown_dir = markdown_dir
return cfg
@app_cli.command()
def main(
storage: str = typer.Option("sqlite", "--storage", help="sqlite or markdown"),
db_path: Path | None = typer.Option(None, "--db-path", help="SQLite file path"),
markdown_dir: Path | None = typer.Option(None, "--markdown-dir", help="Markdown root dir"),
subnet: str | None = typer.Option(None, "--subnet", help="Optional CIDR e.g. 192.168.1.0/24"),
) -> None:
"""Launch the Textual TUI."""
cfg = _build_config(storage, db_path, markdown_dir, subnet)
dm = DeviceManager(cfg, storage_from_config(cfg))
async def _run() -> None:
tui = MainMenu(dm)
await tui.run_async()
asyncio.run(_run())
if __name__ == "__main__":
app_cli()
@@ -0,0 +1 @@
"""Textual screens."""
@@ -0,0 +1,89 @@
"""Main device table."""
from __future__ import annotations
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import DataTable, Footer, Header, Static
from shelly_manager.cli.screens.device_detail import DeviceDetailScreen
from shelly_manager.cli.widgets.filter_bar import FilterBar
from shelly_manager.core.device_manager import DeviceManager
from shelly_manager.core.model_names import format_model_plain
from shelly_manager.core.models import ShellyDevice
class DashboardScreen(Screen[None]):
"""Device list with filters."""
BINDINGS = [
Binding("r", "refresh", "Refresh"),
Binding("d", "detail", "Detail"),
Binding("f", "filter_reload", "apply"),
Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit"),
]
def __init__(self, dm: DeviceManager) -> None:
super().__init__()
self.dm = dm
self._devices: list[ShellyDevice] = []
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical():
yield Static("[bold]Shelly Manager[/] — [dim]r refresh · d detail · f reload filters · q quit[/]")
yield FilterBar(id="filters")
yield DataTable(
id="table",
cursor_type="row",
zebra_stripes=True,
)
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#table", DataTable)
table.add_columns("Name", "IP", "URL", "Gen", "Model", "FW", "Online", "Caps")
self.load_table()
@work(exclusive=True)
async def load_table(self) -> None:
filt = self.query_one(FilterBar).build_filter()
devices = await self.dm.storage.list_devices(filt)
self._devices = devices
table = self.query_one("#table", DataTable)
table.clear(columns=True)
table.add_columns("Name", "IP", "URL", "Gen", "Model", "FW", "Online", "Caps")
for d in devices:
caps = ", ".join(d.capabilities[:3])
table.add_row(
d.display_name,
d.ip,
d.http_url,
str(d.generation),
format_model_plain(d.model),
d.firmware[:16] if d.firmware else "",
"yes" if d.online else "no",
caps,
key=d.id,
)
def action_refresh(self) -> None:
self.load_table()
def action_filter_reload(self) -> None:
self.load_table()
def action_detail(self) -> None:
table = self.query_one("#table", DataTable)
row = table.cursor_row
if row is None or row < 0 or row >= len(self._devices):
return
dev = self._devices[row]
self.app.push_screen(DeviceDetailScreen(self.dm, dev))
def action_quit(self) -> None:
self.app.exit()
@@ -0,0 +1,84 @@
"""Single-device extended view."""
from __future__ import annotations
import json
from rich.console import Group
from rich.syntax import Syntax
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container
from textual.screen import Screen
from textual.widgets import Footer, Header, Static
from shelly_manager.core.device_manager import DeviceManager
from shelly_manager.core.model_names import format_model_plain
from shelly_manager.core.models import ShellyDevice
class DeviceDetailScreen(Screen[None]):
BINDINGS = [
Binding("escape", "back", "Back"),
Binding("r", "refresh", "Refresh"),
]
def __init__(self, dm: DeviceManager, device: ShellyDevice) -> None:
super().__init__()
self.dm = dm
self.device = device
def compose(self) -> ComposeResult:
yield Header()
with Container(id="detail_wrap"):
yield Static(id="detail_text")
yield Footer()
def on_mount(self) -> None:
self._render()
def action_refresh(self) -> None:
self.run_worker(self._do_refresh, exclusive=True)
async def _do_refresh(self) -> None:
fresh = await self.dm.refresh_device(self.device.id)
if fresh:
self.device = fresh
self._render()
def _render(self) -> None:
d = self.device
# Use Rich renderables (Group / Text / Syntax), not Textual markup strings: URLs like
# ``http://...`` break ``[link=...]`` parsing (``:`` after ``http``), and JSON often
# contains ``[`` / ``]`` which Textual would treat as markup.
header = Text()
header.append(d.display_name, style="bold")
header.append(
f" {d.ip} Gen{d.generation} {format_model_plain(d.model)}\nWeb UI: ",
)
header.append(d.http_url, style=f"link {d.http_url}")
header.append("\n")
tip = Text.from_markup(
"[dim]Tip: run `shelly-manager-ui` → Device page for form-based config edit + save.[/dim]\n\n"
)
settings_json = json.dumps(d.settings, indent=2)[:12000]
status_json = json.dumps(d.status, indent=2)[:12000]
settings_block = Syntax(settings_json, "json", word_wrap=True, line_numbers=False)
status_block = Syntax(status_json, "json", word_wrap=True, line_numbers=False)
body: Group = Group(
header,
tip,
Text("Settings\n", style="bold"),
settings_block,
Text("\n"),
Text("Status\n", style="bold"),
status_block,
)
self.query_one("#detail_text", Static).update(body)
def action_back(self) -> None:
self.dismiss()
@@ -0,0 +1,43 @@
"""Discovery progress log."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Footer, Header, Log
from shelly_manager.core.device_manager import DeviceManager
class DiscoveryScreen(Screen[None]):
BINDINGS = [Binding("escape", "back", "Back")]
def __init__(self, dm: DeviceManager) -> None:
super().__init__()
self.dm = dm
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Log(id="log", highlight=True)
yield Footer()
def on_mount(self) -> None:
self.run_worker(self._run_discovery, exclusive=True)
async def _run_discovery(self) -> None:
log = self.query_one("#log", Log)
def on_progress(phase: str, msg: str) -> None:
log.write_line(f"[{phase}] {msg}")
import aiohttp
async with aiohttp.ClientSession() as session:
await self.dm.discover_all(session=session, on_progress=on_progress)
log.write_line("[done] Discovery finished.")
def action_back(self) -> None:
self.dismiss()
@@ -0,0 +1,44 @@
"""Mass tag assignment via filters."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Input, Static
from shelly_manager.cli.widgets.filter_bar import FilterBar
from shelly_manager.core.device_manager import DeviceManager
class MassConfigScreen(Screen[None]):
BINDINGS = [Binding("escape", "back", "Back")]
def __init__(self, dm: DeviceManager) -> None:
super().__init__()
self.dm = dm
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Static("[bold]Mass tags[/] — apply comma-separated tags to filtered devices.")
yield FilterBar(id="filters")
yield Input(placeholder="tags e.g. kitchen, floor-1", id="tags")
yield Button("Apply", variant="primary", id="apply")
yield Static(id="result")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "apply":
self.run_worker(self._apply_tags, exclusive=True)
async def _apply_tags(self) -> None:
filt = self.query_one(FilterBar).build_filter()
raw = self.query_one("#tags", Input).value or ""
tags = [t.strip() for t in raw.split(",") if t.strip()]
n = await self.dm.apply_mass_tags(filt, tags)
self.query_one("#result", Static).update(f"Updated {n} device(s).")
def action_back(self) -> None:
self.dismiss()
@@ -0,0 +1,31 @@
"""Compact device summary for lists."""
from __future__ import annotations
import json
from textual.widgets import Static
from shelly_manager.core.model_names import format_model_plain
from shelly_manager.core.models import ShellyDevice
def device_card_text(d: ShellyDevice, extended: bool = False) -> str:
"""Rich-formatted card text."""
cap = ", ".join(d.capabilities[:5]) if d.capabilities else ""
line = (
f"[bold]{d.display_name}[/] [dim]{d.ip}[/] "
f"[cyan]Gen{d.generation}[/] [magenta]{format_model_plain(d.model)}[/] "
f"{'[green]online[/]' if d.online else '[red]offline[/]'}"
)
if not extended:
return line + f"\n [dim]{cap}[/]"
st = json.dumps(d.status, indent=2)[:2000]
return line + f"\n[dim]{cap}[/]\n\n[bold]Status[/]\n{st}"
class DeviceCard(Static):
"""Static panel showing one device."""
def __init__(self, device: ShellyDevice, extended: bool = False, **kwargs: object) -> None:
super().__init__(device_card_text(device, extended=extended), **kwargs)
@@ -0,0 +1,178 @@
"""Filter inputs for dashboard / mass config (parity with Streamlit device filters)."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Input, Label, Select
from textual.widgets._select import NULL as SELECT_NULL
from shelly_manager.core.mass_config import (
SETTINGS_FILTER_PRESETS,
STATUS_FILTER_PRESETS,
get_settings_preset,
get_status_preset,
)
from shelly_manager.core.models import DeviceFilter
class FilterBar(Vertical):
"""Generation, auth, text search, settings/status presets."""
DEFAULT_CSS = """
FilterBar {
height: auto;
margin: 1 0;
}
FilterBar Horizontal {
height: auto;
margin-bottom: 1;
}
FilterBar Input {
min-width: 14;
}
FilterBar .filter_label {
width: auto;
margin-right: 1;
}
"""
def compose(self) -> ComposeResult:
with Horizontal():
yield Label("Gen:", classes="filter_label")
yield Select(
[("All", "all"), ("Gen1", "1"), ("Gen2+", "2")],
id="filter_gen",
value="all",
classes="filter_ctl",
)
yield Label("Online:", classes="filter_label")
yield Select(
[("any", "any"), ("yes", "yes"), ("no", "no")],
id="filter_online",
value="any",
classes="filter_ctl",
)
yield Label("Auth:", classes="filter_label")
yield Select(
[("any", "any"), ("yes", "yes"), ("no", "no")],
id="filter_auth",
value="any",
classes="filter_ctl",
)
with Horizontal():
yield Label("Search:", classes="filter_label")
yield Input(
placeholder="name / id / model substring…",
id="filter_name",
classes="filter_ctl",
)
with Horizontal():
yield Label("Model:", classes="filter_label")
yield Input(placeholder="model contains…", id="filter_model", classes="filter_ctl")
yield Label("FW:", classes="filter_label")
yield Input(placeholder="firmware…", id="filter_fw", classes="filter_ctl")
yield Label("IP:", classes="filter_label")
yield Input(placeholder="IP prefix…", id="filter_ip", classes="filter_ctl")
with Horizontal():
yield Label("MAC:", classes="filter_label")
yield Input(placeholder="exact MAC", id="filter_mac", classes="filter_ctl")
yield Label("Tags:", classes="filter_label")
yield Input(placeholder="t1, t2", id="filter_tags", classes="filter_ctl")
yield Label("Caps:", classes="filter_label")
yield Input(placeholder="relay, mqtt, …", id="filter_caps", classes="filter_ctl")
with Horizontal():
yield Label("Settings:", classes="filter_label")
yield Select(
[("", "none")] + [(p.label, p.id) for p in SETTINGS_FILTER_PRESETS],
id="filter_set_preset",
value="none",
classes="filter_ctl",
)
yield Label("Status:", classes="filter_label")
yield Select(
[("", "none")] + [(p.label, p.id) for p in STATUS_FILTER_PRESETS],
id="filter_stat_preset",
value="none",
classes="filter_ctl",
)
def _str(self, wid: str) -> str:
w = self.query_one(f"#{wid}", Input)
return (w.value or "").strip()
def _sel_val(self, wid: str) -> str:
w = self.query_one(f"#{wid}", Select)
raw = w.value
if raw is None or raw is SELECT_NULL:
return "none"
return str(raw)
def build_filter(self) -> DeviceFilter:
gv = self._sel_val("filter_gen")
gens: list[int] | None = None
if gv == "1":
gens = [1]
elif gv == "2":
gens = [2, 3]
online_only: bool | None = None
o = self._sel_val("filter_online")
if o == "yes":
online_only = True
elif o == "no":
online_only = False
auth_required: bool | None = None
a = self._sel_val("filter_auth")
if a == "yes":
auth_required = True
elif a == "no":
auth_required = False
name = self._str("filter_name") or None
model_sub = self._str("filter_model") or None
fw = self._str("filter_fw") or None
ip_prefix = self._str("filter_ip") or None
mac = self._str("filter_mac") or None
tags_raw = self._str("filter_tags")
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] or None
caps_raw = self._str("filter_caps")
capabilities_any = [c.strip() for c in caps_raw.split(",") if c.strip()] or None
settings_path = None
settings_match = None
sp = self._sel_val("filter_set_preset")
if sp != "none":
pr = get_settings_preset(sp)
if pr:
settings_path = pr.settings_path
settings_match = pr.settings_match
status_path = None
status_match = None
stp = self._sel_val("filter_stat_preset")
if stp != "none":
sr = get_status_preset(stp)
if sr:
status_path = sr.status_path
status_match = sr.status_match
return DeviceFilter(
generations=gens,
online_only=online_only,
auth_required=auth_required,
name_contains=name,
model_contains=model_sub,
firmware_contains=fw,
ip_prefix=ip_prefix,
mac=mac,
tags=tags,
capabilities_any=capabilities_any,
settings_path=settings_path,
settings_match=settings_match,
status_path=status_path,
status_match=status_match,
)
@@ -0,0 +1,13 @@
"""Online/offline indicator."""
from __future__ import annotations
from textual.widgets import Static
class StatusBadge(Static):
"""Small colored status line."""
def __init__(self, online: bool, **kwargs: object) -> None:
label = "[green]● online[/]" if online else "[red]● offline[/]"
super().__init__(label, **kwargs)
+38
View File
@@ -0,0 +1,38 @@
"""Core models and orchestration.
Avoid importing :class:`~shelly_manager.core.device_manager.DeviceManager` at package
import time: ``api.client`` imports ``core.discovery``, which loads this package, and
``DeviceManager`` imports ``api.client`` (circular import).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from shelly_manager.core.config import AppConfig
from shelly_manager.core.models import (
ConfigSnapshot,
DeviceFilter,
DiscoveredEndpoint,
ShellyDevice,
)
if TYPE_CHECKING:
from shelly_manager.core.device_manager import DeviceManager as DeviceManager
__all__ = [
"AppConfig",
"ConfigSnapshot",
"DeviceFilter",
"DiscoveredEndpoint",
"ShellyDevice",
"DeviceManager",
]
def __getattr__(name: str) -> Any:
if name == "DeviceManager":
from shelly_manager.core.device_manager import DeviceManager
return DeviceManager
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+38
View File
@@ -0,0 +1,38 @@
"""Application configuration."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
class AppConfig(BaseModel):
"""Runtime configuration for CLI and UI."""
storage_backend: Literal["sqlite", "markdown"] = "sqlite"
sqlite_path: Path = Field(default=Path("./data/shelly_devices.db"))
markdown_dir: Path = Field(default=Path("./data/devices_md"))
poll_interval_sec: float = Field(default=30.0, ge=1.0)
subnet_scan_cidr: str | None = Field(
default=None,
description="Optional e.g. 192.168.1.0/24 for HTTP discovery scan",
)
discovery_http_timeout_sec: float = Field(
default=3.0,
ge=0.5,
le=30.0,
description="Timeout per GET /shelly during subnet scan and manual add-by-IP.",
)
mdns_timeout_sec: float = Field(default=5.0, ge=0.5)
subnet_concurrency: int = Field(default=64, ge=1, le=256)
auto_refresh: bool = Field(default=False)
#: Max stored config snapshots per device (before/after edits, mass ops); oldest trimmed.
config_snapshot_max_per_device: int = Field(default=10, ge=1, le=500)
#: After a Mass Config bulk RPC run, live-refresh all devices in the current filtered table.
mass_config_refresh_after_bulk: bool = Field(default=True)
#: How many recent config snapshots to list per device in Dashboard / Mass Config tables (0 = show "—").
inventory_versions_shown: int = Field(default=3, ge=0, le=50)
model_config = {"arbitrary_types_allowed": True}
+37
View File
@@ -0,0 +1,37 @@
"""Helpers for merging partial GetConfig updates (Gen2+ mass section apply)."""
from __future__ import annotations
import copy
from typing import Any
def deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge *patch* into a copy of *base* (dict values merge; other values replace)."""
out = copy.deepcopy(base)
for k, v in patch.items():
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
out[k] = deep_merge_dict(out[k], v)
else:
out[k] = copy.deepcopy(v)
return out
# Curated top-level Shelly.GetConfig keys (Gen2+). Values must be JSON objects for Component.SetConfig.
SECTION_KEY_CHOICES: list[tuple[str, str]] = [
("mqtt", "MQTT — broker, TLS, client id, …"),
("wifi", "WiFi — AP + STA (shape varies by firmware)"),
("wifi_sta", "WiFi STA — credentials (if a separate top-level key)"),
("wifi_ap", "WiFi AP — soft-AP"),
("sntp", "SNTP / time — server, enable"),
("coiot", "CoIoT — UDP peer, update period"),
("cloud", "Shelly Cloud"),
("sys", "System — name, location, debug, …"),
("ble", "Bluetooth LE"),
("eth", "Ethernet"),
("ws", "WebSocket / RPC"),
("mdns", "mDNS"),
("__custom__", "Other — enter a custom top-level key below"),
]
__all__ = ["SECTION_KEY_CHOICES", "deep_merge_dict"]

Some files were not shown because too many files have changed in this diff Show More