Skip to content

Commit 146def9

Browse files
authored
Merge pull request #863 from contagon/strict-types
Tweaks in type stubs generation
2 parents b08de4a + e375568 commit 146def9

3 files changed

Lines changed: 130 additions & 23 deletions

File tree

‎custom_components/pyscript/stubs/generator.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from custom_components.pyscript.stubs.pyscript_builtins import StateVal
1313
from homeassistant.core import HomeAssistant, split_entity_id
14-
from homeassistant.helpers import entity_registry as er
1514
from homeassistant.helpers.service import async_get_all_descriptions
1615

1716
_LOGGER = logging.getLogger(__name__)
@@ -70,10 +69,11 @@ async def build(self) -> str:
7069
}
7170

7271
for module, imports in base_imports.items():
72+
level = 1 if module == "pyscript_builtins" else 0
7373
module_body.append(
7474
ast.ImportFrom(
7575
module=module,
76-
level=0,
76+
level=level,
7777
names=[ast.alias(name=imp, asname=None) for imp in imports],
7878
)
7979
)
@@ -163,13 +163,11 @@ def _get_entity_class_name(self, domain_id: str) -> str:
163163
return f"_{domain_id}{_STATE_CLASS_SUFFIX}"
164164

165165
async def _build_entity_classes(self):
166-
for entity in er.async_get(self._hass).entities.values():
167-
if entity.disabled:
168-
continue
169-
170-
domain_id, entity_id = split_entity_id(entity.entity_id)
166+
for state_obj in sorted(self._hass.states.async_all(), key=lambda s: s.entity_id):
167+
full_entity_id = state_obj.entity_id
168+
domain_id, entity_id = split_entity_id(full_entity_id)
171169

172-
if not self._is_identifier(entity_id, entity.entity_id):
170+
if not self._is_identifier(entity_id, full_entity_id):
173171
continue
174172

175173
self._collect_entity_atts(domain_id, entity_id)
@@ -235,6 +233,7 @@ async def _create_service_function(
235233
kwonlyargs: list[ast.arg] = []
236234
kw_defaults: list[ast.expr] = []
237235
decorator_list: list[ast.expr] = []
236+
defaults: list[ast.expr] = []
238237

239238
has_target = "target" in payload
240239

@@ -258,6 +257,9 @@ async def _create_service_function(
258257

259258
if def_type == "entity" and len(field_nodes) == 1: # simple calling with 1 arg service
260259
args.append(ast.arg(arg=field_nodes[0].name, annotation=field_nodes[0].annotation))
260+
# Preserve the default for the single positional argument.
261+
if field_nodes[0].default is not None:
262+
defaults.append(field_nodes[0].default)
261263
else:
262264
for field in field_nodes:
263265
kwonlyargs.append(ast.arg(arg=field.name, annotation=field.annotation))
@@ -287,7 +289,7 @@ async def _create_service_function(
287289
kwonlyargs=kwonlyargs,
288290
kw_defaults=kw_defaults,
289291
kwarg=None,
290-
defaults=[],
292+
defaults=defaults,
291293
),
292294
body=body,
293295
decorator_list=decorator_list,
@@ -330,6 +332,13 @@ def _describe_service_field(
330332
if default_value is not None and isinstance(default_value, (int, float, str, bool)):
331333
default_expr = ast.Constant(value=default_value)
332334

335+
# Widen annotation when the default's type conflicts with the selector type.
336+
if default_expr is not None and annotation is not None:
337+
ann_str = ast.unparse(annotation)
338+
default_type = type(default_value).__name__
339+
if default_type not in ann_str and default_type in ("int", "float", "str", "bool"):
340+
annotation = ast.BinOp(left=annotation, op=ast.BitOr(), right=self._name(default_type))
341+
333342
if not is_required:
334343
if default_expr is None:
335344
if annotation is not None:
@@ -361,8 +370,14 @@ def _selector_annotation(self, selector: dict[str, Any] | None) -> ast.expr | No
361370
if selector_id == "number":
362371
if selector_value == "any":
363372
return self._name("float")
364-
if isinstance(selector_value, dict) and selector_value.get("mode") == "box":
365-
return self._name("float")
373+
if isinstance(selector_value, dict):
374+
if selector_value.get("mode") == "box":
375+
return self._name("float")
376+
# Use float when step or min/max are fractional.
377+
for key in ("step", "min", "max"):
378+
val = selector_value.get(key)
379+
if isinstance(val, float) and val != int(val):
380+
return self._name("float")
366381
return self._name("int")
367382
if selector_id == "select":
368383
options = []

‎custom_components/pyscript/stubs/pyscript_builtins.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
from datetime import datetime
1313
from typing import Any, Literal
1414

15-
from homeassistant.components.webhook import SUPPORTED_METHODS
1615
from homeassistant.core import HomeAssistant
1716

1817
hass: HomeAssistant
1918

19+
WebhookMethod = Literal["GET", "HEAD", "POST", "PUT"]
20+
2021

2122
def service(
2223
*service_name: str, supports_response: Literal["none", "only", "optional"] = "none"
@@ -129,7 +130,7 @@ def webhook_trigger(
129130
webhook_id: str,
130131
str_expr: str | None = None,
131132
local_only: bool = True,
132-
methods: set[SUPPORTED_METHODS] | list[SUPPORTED_METHODS] = {"POST", "PUT"},
133+
methods: set[WebhookMethod] | list[WebhookMethod] = {"POST", "PUT"},
133134
kwargs: dict | None = None,
134135
) -> Callable[..., Any]:
135136
"""Trigger when a request is made to a webhook endpoint.
@@ -150,7 +151,7 @@ def webhook_handler(
150151
webhook_id: str,
151152
str_expr: str | None = None,
152153
local_only: bool = True,
153-
methods: set[SUPPORTED_METHODS] | list[SUPPORTED_METHODS] = {"POST", "PUT"},
154+
methods: set[WebhookMethod] | list[WebhookMethod] = {"POST", "PUT"},
154155
timeout: int | float = 10.0,
155156
kwargs: dict | None = None,
156157
) -> Callable[..., Any]:
@@ -491,7 +492,7 @@ def wait_until(
491492
mqtt_trigger_encoding: str | None = None,
492493
webhook_trigger: str | list[str] | None = None,
493494
webhook_local_only: bool = True,
494-
webhook_methods: list[SUPPORTED_METHODS] = ("POST", "PUT"),
495+
webhook_methods: list[WebhookMethod] = ["POST", "PUT"],
495496
timeout: int | float | None = None,
496497
state_check_now: bool = True,
497498
state_hold: int | float | None = None,

‎tests/test_stubs.py‎

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from datetime import datetime as dt
66
from pathlib import Path
7-
from types import SimpleNamespace
87
from typing import Any
98

109
import pytest
@@ -40,12 +39,7 @@ def ready():
4039
},
4140
)
4241

43-
dummy_registry = SimpleNamespace(
44-
entities={
45-
"light.lamp": SimpleNamespace(entity_id="light.lamp", disabled=False),
46-
}
47-
)
48-
monkeypatch.setattr("custom_components.pyscript.stubs.generator.er.async_get", lambda _: dummy_registry)
42+
hass.states.async_set("remote.tv", "off", {})
4943

5044
async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str, dict[str, Any]]]:
5145
return {
@@ -68,7 +62,44 @@ async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str,
6862
},
6963
"response": {"optional": True},
7064
}
71-
}
65+
},
66+
"remote": {
67+
"send_command": {
68+
"description": "Send a command.",
69+
"fields": {
70+
"delay_secs": {
71+
"required": False,
72+
"default": 0.4,
73+
"selector": {"number": {"step": 0.1, "min": 0.0, "max": 5.0}},
74+
"description": "Delay between commands.",
75+
},
76+
},
77+
},
78+
"turn_on": {
79+
"description": "Turn on remote.",
80+
"target": {"entity": {"domain": "remote"}},
81+
"fields": {
82+
"activity": {
83+
"required": False,
84+
"selector": {"text": None},
85+
"description": "Activity to start.",
86+
},
87+
},
88+
},
89+
},
90+
"voice_satellite": {
91+
"show": {
92+
"description": "Show something.",
93+
"fields": {
94+
"pipeline": {
95+
"required": False,
96+
"default": 1,
97+
"selector": {"text": None},
98+
"description": "Pipeline name or slot.",
99+
},
100+
},
101+
},
102+
},
72103
}
73104

74105
monkeypatch.setattr(
@@ -115,6 +146,13 @@ async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str,
115146
assert "'fast'" in generated_content
116147
assert "-> dict[str, Any]" in generated_content
117148

149+
# Fractional number selector produces float annotation.
150+
assert "delay_secs: float" in generated_content
151+
# Type mismatch between selector (str) and default (int) widens annotation.
152+
assert "str | int" in generated_content
153+
# Entity service with single optional arg preserves the default.
154+
assert "turn_on(self, activity: str | None=None)" in generated_content
155+
118156
original_builtins = (
119157
Path(__file__).resolve().parent.parent
120158
/ "custom_components"
@@ -156,3 +194,56 @@ def stub_import_ready():
156194

157195
assert hass.services.has_service(DOMAIN, "stub_import_ready")
158196
assert "ModuleNotFoundError" not in caplog.text
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_stubs_include_state_only_entities(hass, caplog, monkeypatch):
201+
"""Entities only in the state machine (not in entity registry) must appear in stubs."""
202+
203+
await setup_script(
204+
hass,
205+
notify_q=None,
206+
now=dt(2024, 3, 3, 0, 0, 0),
207+
source="""
208+
@service
209+
def ready2():
210+
pass
211+
""",
212+
script_name="/stub_state_only.py",
213+
)
214+
215+
# Entity in state machine but NOT in the registry (template entity / zone.home pattern).
216+
hass.states.async_set(
217+
"binary_sensor.my_sensor",
218+
"off",
219+
{"device_class": "running", "friendly_name": "Van Car Running"},
220+
)
221+
hass.states.async_set(
222+
"zone.home",
223+
"0",
224+
{"latitude": 40.0, "longitude": -74.0, "radius": 100, "persons": ["person.me"]},
225+
)
226+
227+
# Empty registry - forces all entities to come from state machine only.
228+
229+
async def fake_service_descriptions(_hass: HomeAssistant) -> dict[str, dict[str, dict[str, Any]]]:
230+
return {}
231+
232+
monkeypatch.setattr(
233+
"custom_components.pyscript.stubs.generator.async_get_all_descriptions", fake_service_descriptions
234+
)
235+
236+
stubs_dir = Path(hass.config.path(FOLDER)) / "modules" / "stubs"
237+
generated_target = stubs_dir / "pyscript_generated.py"
238+
stubs_dir.mkdir(parents=True, exist_ok=True)
239+
240+
await hass.services.async_call(DOMAIN, SERVICE_GENERATE_STUBS, {}, blocking=True, return_response=True)
241+
242+
content = generated_target.read_text(encoding="utf-8")
243+
assert "my_sensor: _binary_sensor_state" in content
244+
assert "home: _zone_state" in content
245+
246+
# Cleanup
247+
for child in stubs_dir.iterdir():
248+
child.unlink()
249+
stubs_dir.rmdir()

0 commit comments

Comments
 (0)