diff --git a/custom_components/pyscript/stubs/generator.py b/custom_components/pyscript/stubs/generator.py index 16dd069..2d2e393 100644 --- a/custom_components/pyscript/stubs/generator.py +++ b/custom_components/pyscript/stubs/generator.py @@ -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) @@ -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) diff --git a/tests/test_stubs.py b/tests/test_stubs.py index 10dd3d8..a402421 100644 --- a/tests/test_stubs.py +++ b/tests/test_stubs.py @@ -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()