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
11 changes: 10 additions & 1 deletion custom_components/pyscript/stubs/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ async def _build_services(self):
def process_fields(fields: dict[str, Any]) -> list[_ServiceField]:
result: list[_ServiceField] = []
for field_name, field in (fields.get("fields") or {}).items():
if field_name in ("additional_fields", "advanced_fields"):
if isinstance(field, dict) and "fields" in field and "selector" not in field:
# a section groups fields in the UI only, service data stays flat
result.extend(process_fields(field))
continue
definition = self._describe_service_field(service_id, field_name, field)
Expand Down Expand Up @@ -364,6 +365,14 @@ def _describe_service_field(
def _selector_annotation(self, selector: dict[str, Any] | None) -> ast.expr | None:
if not selector:
return None
annotation = self._selector_base_annotation(selector)
if annotation is not None and any(
isinstance(value, dict) and value.get("multiple") is True for value in selector.values()
):
return ast.Subscript(value=self._name("list"), slice=annotation)
return annotation

def _selector_base_annotation(self, selector: dict[str, Any]) -> ast.expr | None:
for selector_id, selector_value in selector.items():
if selector_type := SELECTOR_SIMPLE_TYPES.get(selector_id):
return self._name(selector_type)
Expand Down
70 changes: 70 additions & 0 deletions tests/test_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,73 @@ async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str,
for child in stubs_dir.iterdir():
child.unlink()
stubs_dir.rmdir()


@pytest.mark.asyncio
async def test_stubs_flatten_named_sections_and_multiple(pyscript, monkeypatch):
"""Sections of any name must be flattened, and "multiple" selectors annotated as lists."""

hass = pyscript.hass

async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str, dict[str, Any]]]:
return {
"mailer": {
"send": {
"description": "Send a message.",
"fields": {
"recipients": {
"required": True,
"selector": {"text": {"multiple": True}},
"description": "Recipients.",
},
"subject": {
"required": False,
"selector": {"text": None},
"description": "Subject.",
},
"delivery_options": {
"collapsed": True,
"fields": {
"channels": {
"required": False,
"selector": {"select": {"options": ["email", "sms"], "multiple": True}},
"description": "Channels.",
},
"camera": {
"required": False,
"selector": {"entity": {"domain": "camera"}},
"description": "Camera.",
},
},
},
},
}
}
}

monkeypatch.setattr(
"custom_components.pyscript.stubs.generator.async_get_all_descriptions", fake_service_descriptions
)

await pyscript.start()

stubs_dir = Path(hass.config.path(FOLDER)) / "modules" / "stubs"
generated_target = stubs_dir / "pyscript_generated.py"
stubs_dir.mkdir(parents=True, exist_ok=True)

await hass.services.async_call(DOMAIN, SERVICE_GENERATE_STUBS, {}, blocking=True, return_response=True)

generated_content = generated_target.read_text(encoding="utf-8")

# The section itself must not show up as a bogus parameter...
assert "delivery_options" not in generated_content
# ...its fields are flattened into the signature, and "multiple" selectors take a list.
assert "recipients: list[str]" in generated_content
assert "subject: str | None=None" in generated_content
assert "channels: list[Literal['', 'email', 'sms']] | None=None" in generated_content
assert "camera: str | None=None" in generated_content

# Cleanup
for child in stubs_dir.iterdir():
child.unlink()
stubs_dir.rmdir()