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:
@@ -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)
|
||||
Reference in New Issue
Block a user