Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions nitrostack/cli/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,72 @@ def validate_module_references(root: str) -> List[ValidationIssue]:
return issues


_WIDGET_DECORATOR_RE = re.compile(r"""@widget\(\s*["']([^"']+)["']""")
_WIDGET_OPTIONS_RE = re.compile(r"""WidgetOptions\(\s*route\s*=\s*["']([^"']+)["']""")
_WIDGET_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")



def _collect_widget_routes(root: str) -> List[Tuple[str, str]]:
found: List[Tuple[str, str]] = []
seen: Set[str] = set()
root_abs = os.path.abspath(root)

for path in sorted(_iter_python_files(root)):
rel_path = os.path.relpath(path, root_abs)
parts = os.path.normpath(rel_path).split(os.sep)

# Ignore a vendored copy of the NitroStack SDK.
if "vendor" in parts and "nitrostack" in parts:
continue

try:
text = _read(path)
except OSError:
continue

for route in (
_WIDGET_DECORATOR_RE.findall(text)
+ _WIDGET_OPTIONS_RE.findall(text)
):
if route not in seen:
seen.add(route)
found.append((route, rel_path))

return found



def validate_widgets(root: str) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
for route, source in _collect_widget_routes(root):
if not _WIDGET_ROUTE_RE.fullmatch(route):
issues.append(ValidationIssue(
"warning",
f"@widget route {route!r} is not a single alphanumeric path segment",
"Use letters, digits, '-' or '_' only; the route maps to widgets/out/<route>.html.",
source,
))
continue
rel = os.path.join("widgets", "out", f"{route}.html")
uri = f"ui://widget/{route}.html"
html_path = os.path.join(root, rel)
if not os.path.isfile(html_path):
issues.append(ValidationIssue(
"warning",
f"widget route {route!r} -> {rel} -> {uri}: file is missing",
f"Create {rel} with the widget HTML.",
source,
))
elif not _read(html_path).strip():
issues.append(ValidationIssue(
"warning",
f"widget route {route!r} -> {rel} -> {uri}: file is empty",
f"Add widget HTML to {rel}.",
rel,
))
return issues

def validate_project(root: Optional[str] = None) -> List[ValidationIssue]:
root = os.path.abspath(root or os.getcwd())
if root not in sys.path:
Expand All @@ -380,6 +446,7 @@ def validate_project(root: Optional[str] = None) -> List[ValidationIssue]:
issues.extend(validate_dependencies(root))
issues.extend(validate_mcp_app_imports(root))
issues.extend(validate_module_references(root))
issues.extend(validate_widgets(root))
return issues


Expand Down
85 changes: 85 additions & 0 deletions tests/test_validate_widgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import sys
from pathlib import Path

from nitrostack.cli.validators import validate_project, validate_widgets

TOOL = (
"from nitrostack import tool, widget\n\n"
"@tool(name='show')\n"
"@widget('card')\n"
"async def show(self, input, context):\n"
" return {}\n"
)


def _project(tmp_path: Path, source: str = TOOL) -> Path:
(tmp_path / "tools.py").write_text(source, encoding="utf-8")
return tmp_path


def _write_html(root: Path, route: str, html: str) -> None:
out = root / "widgets" / "out"
out.mkdir(parents=True, exist_ok=True)
(out / f"{route}.html").write_text(html, encoding="utf-8")


def test_missing_widget_html_is_a_warning(tmp_path: Path):
issues = validate_widgets(str(_project(tmp_path)))
assert len(issues) == 1
assert issues[0].severity == "warning"
assert "file is missing" in issues[0].message
assert "card.html" in issues[0].message
assert "ui://widget/card.html" in issues[0].message
assert issues[0].path == "tools.py"


def test_empty_widget_html_is_a_warning(tmp_path: Path):
_write_html(_project(tmp_path), "card", " \n")
issues = validate_widgets(str(tmp_path))
assert len(issues) == 1
assert issues[0].severity == "warning"
assert "file is empty" in issues[0].message


def test_valid_widget_html_passes(tmp_path: Path):
_write_html(_project(tmp_path), "card", "<html></html>")
assert validate_widgets(str(tmp_path)) == []


def test_widget_options_route_is_collected(tmp_path: Path):
src = "from nitrostack import WidgetOptions, widget\n\n@widget(WidgetOptions(route='chart'))\ndef f(): ...\n"
issues = validate_widgets(str(_project(tmp_path, src)))
assert len(issues) == 1
assert "'chart'" in issues[0].message


def test_invalid_route_is_reported_not_resolved(tmp_path: Path):
issues = validate_widgets(str(_project(tmp_path, "@widget('../evil')\ndef f(): ...\n")))
assert len(issues) == 1
assert "not a single alphanumeric path segment" in issues[0].message


def test_duplicate_routes_are_reported_once(tmp_path: Path):
(tmp_path / "a.py").write_text(TOOL, encoding="utf-8")
(tmp_path / "b.py").write_text(TOOL, encoding="utf-8")
assert len(validate_widgets(str(tmp_path))) == 1


def test_vendored_sdk_is_ignored(tmp_path: Path):
vendored = tmp_path / "vendor" / "nitrostack"
vendored.mkdir(parents=True)
(vendored / "x.py").write_text(TOOL, encoding="utf-8")
assert validate_widgets(str(tmp_path)) == []


def test_project_without_widgets_has_no_widget_issues(tmp_path: Path):
(tmp_path / "plain.py").write_text("x = 1\n", encoding="utf-8")
assert validate_widgets(str(tmp_path)) == []


def test_validate_project_reports_widget_warnings_only(tmp_path: Path, monkeypatch):
monkeypatch.setattr(sys, "path", list(sys.path))
_project(tmp_path)
issues = [i for i in validate_project(str(tmp_path)) if "widget route" in i.message]
assert len(issues) == 1
assert issues[0].severity == "warning"
Loading