From 7aa4d82c44548aee20d9cd17995438a6805495e3 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 24 Feb 2026 13:36:03 -0600 Subject: [PATCH 1/3] refactor: replace create() classmethods with __init__ constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resource class hierarchy used @classmethod create() factories that instantiated the class, set attributes on the instance, and returned it. This indirection required inspect.signature introspection in add_child() to conditionally pass arguments — fragile and hard to follow. Replace all create() classmethods with standard __init__ constructors that call super().__init__() and set attributes directly. Simplify add_child() to a direct constructor call. Remove the inspect import and the base Resource.create() method entirely. 25 files, -81 lines net. All 1053 tests pass, pyright clean. --- src/hassette/api/api.py | 12 +++---- src/hassette/api/sync.py | 10 +++--- src/hassette/app/app.py | 25 ++++++------- src/hassette/bus/bus.py | 16 ++++----- src/hassette/core/api_resource.py | 10 +++--- src/hassette/core/app_factory.py | 2 +- src/hassette/core/app_handler.py | 27 +++++++------- src/hassette/core/bus_service.py | 25 +++++++------ src/hassette/core/core.py | 2 +- src/hassette/core/data_sync_service.py | 18 +++++----- src/hassette/core/scheduler_service.py | 29 ++++++--------- src/hassette/core/service_watcher.py | 10 +++--- src/hassette/core/state_proxy.py | 28 +++++---------- src/hassette/core/web_api_service.py | 12 +++---- src/hassette/core/websocket_service.py | 26 +++++++------- src/hassette/resources/base.py | 27 ++------------ src/hassette/scheduler/scheduler.py | 16 ++++----- src/hassette/state_manager/state_manager.py | 20 +++-------- src/hassette/state_manager/state_manager.pyi | 3 +- src/hassette/task_bucket/task_bucket.py | 12 +++---- src/hassette/test_utils/harness.py | 2 +- tests/integration/test_service_watcher.py | 2 +- tests/integration/test_state_proxy.py | 2 +- tests/integration/test_states.py | 38 ++++++++++---------- tests/integration/test_websocket_service.py | 2 +- tests/unit/test_app_factory.py | 18 +++++----- 26 files changed, 156 insertions(+), 238 deletions(-) diff --git a/src/hassette/api/api.py b/src/hassette/api/api.py index c9bd18445..75d309f4a 100644 --- a/src/hassette/api/api.py +++ b/src/hassette/api/api.py @@ -195,13 +195,11 @@ class Api(Resource): _api_service: "ApiResource" """Internal API service instance.""" - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - inst = cls(hassette=hassette, parent=parent) - inst._api_service = inst.hassette._api_service - inst.sync = inst.add_child(ApiSyncFacade, api=inst) - inst.mark_ready(reason="API initialized") - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self._api_service = self.hassette._api_service + self.sync = self.add_child(ApiSyncFacade, api=self) + self.mark_ready(reason="API initialized") @property def config_log_level(self): diff --git a/src/hassette/api/sync.py b/src/hassette/api/sync.py index 686914968..33ebc05ca 100644 --- a/src/hassette/api/sync.py +++ b/src/hassette/api/sync.py @@ -38,12 +38,10 @@ class ApiSyncFacade(Resource): _api: "Api" - @classmethod - def create(cls, hassette: "Hassette", api: "Api"): - inst = cls(hassette, parent=api) - inst._api = api - inst.mark_ready(reason="Synchronous API facade initialized") - return inst + def __init__(self, hassette: "Hassette", *, api: "Api", parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self._api = api + self.mark_ready(reason="Synchronous API facade initialized") def ws_send_and_wait(self, **data: Any) -> Any: """Send a WebSocket message and wait for a response.""" diff --git a/src/hassette/app/app.py b/src/hassette/app/app.py index 6d216d111..47b5a2b76 100644 --- a/src/hassette/app/app.py +++ b/src/hassette/app/app.py @@ -83,23 +83,18 @@ class App(Generic[AppConfigT], Resource, metaclass=FinalMeta): index: int """Index of this app instance, used for unique naming.""" - def __init__(self, *args, app_config: AppConfigT, index: int, **kwargs): - # unlike most classes, this one does take additional init args - # this is because the unique name we use for the logger depends on the app config + def __init__( + self, hassette: "Hassette", *, app_config: AppConfigT, index: int, parent: Resource | None = None + ) -> None: + # app_config and index must be set before super().__init__ because + # unique_name (used by the logger) depends on app_config self.app_config = app_config self.index = index - super().__init__(*args, **kwargs) - - @classmethod - def create(cls, hassette: "Hassette", app_config: AppConfigT, index: int): - inst = cls(hassette=hassette, app_config=app_config, index=index) - inst.app_config = app_config - inst.index = index - inst.api = inst.add_child(Api) - inst.scheduler = inst.add_child(Scheduler) - inst.bus = inst.add_child(Bus, priority=0) - inst.states = inst.add_child(StateManager) - return inst + super().__init__(hassette, parent=parent) + self.api = self.add_child(Api) + self.scheduler = self.add_child(Scheduler) + self.bus = self.add_child(Bus, priority=0) + self.states = self.add_child(StateManager) @property def unique_name(self) -> str: diff --git a/src/hassette/bus/bus.py b/src/hassette/bus/bus.py index ebd4ab047..33156d6dc 100644 --- a/src/hassette/bus/bus.py +++ b/src/hassette/bus/bus.py @@ -125,15 +125,13 @@ class Bus(Resource): priority: int = 0 """Priority level for event handlers created by this bus.""" - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource", priority: int = 0): - inst = cls(hassette=hassette, parent=parent) - inst.bus_service = inst.hassette._bus_service - inst.priority = priority - - assert inst.bus_service is not None, "Bus service not initialized" - inst.mark_ready(reason="Bus initialized") - return inst + def __init__(self, hassette: "Hassette", *, priority: int = 0, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self.bus_service = self.hassette._bus_service + self.priority = priority + + assert self.bus_service is not None, "Bus service not initialized" + self.mark_ready(reason="Bus initialized") async def on_shutdown(self) -> None: """Cleanup all listeners owned by this bus's owner on shutdown.""" diff --git a/src/hassette/core/api_resource.py b/src/hassette/core/api_resource.py index f6d8ff8c3..67f6c4d3d 100644 --- a/src/hassette/core/api_resource.py +++ b/src/hassette/core/api_resource.py @@ -50,12 +50,10 @@ class ApiResource(Resource): _session: aiohttp.ClientSession | None """HTTP client session for making requests.""" - @classmethod - def create(cls, hassette: "Hassette"): - inst = cls(hassette, parent=hassette) - inst._stack = AsyncExitStack() - inst._session = None - return inst + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self._stack = AsyncExitStack() + self._session = None async def on_initialize(self): """ diff --git a/src/hassette/core/app_factory.py b/src/hassette/core/app_factory.py index 85182ddc3..a416bdfec 100644 --- a/src/hassette/core/app_factory.py +++ b/src/hassette/core/app_factory.py @@ -68,7 +68,7 @@ def create_instances( try: validated = app_class.app_config_cls.model_validate(config) - app_instance = app_class.create( + app_instance = app_class( hassette=self.hassette, app_config=validated, index=idx, diff --git a/src/hassette/core/app_handler.py b/src/hassette/core/app_handler.py index dbb423db7..f4563c205 100644 --- a/src/hassette/core/app_handler.py +++ b/src/hassette/core/app_handler.py @@ -54,26 +54,23 @@ class AppHandler(Resource): bus: Bus """Event bus for inter-service communication.""" - @classmethod - def create(cls, hassette: "Hassette"): - inst = cls(hassette, parent=hassette) + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) # Initialize components - inst.registry = AppRegistry() - inst.factory = AppFactory(hassette, inst.registry) - inst.change_detector = AppChangeDetector() - inst.set_apps_configs(hassette.config.app_manifests) - inst.lifecycle = AppLifecycleManager(hassette, inst.registry) + self.registry = AppRegistry() + self.factory = AppFactory(hassette, self.registry) + self.change_detector = AppChangeDetector() + self.set_apps_configs(hassette.config.app_manifests) + self.lifecycle = AppLifecycleManager(hassette, self.registry) - inst.registry.logger.setLevel(inst.config_log_level) - inst.factory.logger.setLevel(inst.config_log_level) - inst.lifecycle.logger.setLevel(inst.config_log_level) - inst.change_detector.logger.setLevel(inst.config_log_level) + self.registry.logger.setLevel(self.config_log_level) + self.factory.logger.setLevel(self.config_log_level) + self.lifecycle.logger.setLevel(self.config_log_level) + self.change_detector.logger.setLevel(self.config_log_level) # Event bus for status events - inst.bus = inst.add_child(Bus) - - return inst + self.bus = self.add_child(Bus) # --- Public API --- diff --git a/src/hassette/core/bus_service.py b/src/hassette/core/bus_service.py index 040f61952..43899ef43 100644 --- a/src/hassette/core/bus_service.py +++ b/src/hassette/core/bus_service.py @@ -13,7 +13,7 @@ from hassette.bus.metrics import ListenerMetrics from hassette.events import Event, HassPayload from hassette.exceptions import DependencyError, HassetteError -from hassette.resources.base import Service +from hassette.resources.base import Resource, Service from hassette.utils.glob_utils import GLOB_CHARS, matches_globs, split_exact_and_glob from hassette.utils.hass_utils import split_entity_id, valid_entity_id @@ -49,16 +49,19 @@ class BusService(Service): Metrics persist after listener removal (~200 bytes each) to preserve historical data for the web UI. This is intentional and not a leak.""" - @classmethod - def create(cls, hassette: "Hassette", stream: "MemoryObjectReceiveStream[tuple[str, Event[Any]]]"): - inst = cls(hassette, parent=hassette) - inst.stream = stream - inst.listener_seq = itertools.count(1) - inst.router = Router() - inst._listener_metrics = {} - inst._setup_exclusion_filters() - - return inst + def __init__( + self, + hassette: "Hassette", + *, + stream: "MemoryObjectReceiveStream[tuple[str, Event[Any]]]", + parent: "Resource | None" = None, + ) -> None: + super().__init__(hassette, parent=parent) + self.stream = stream + self.listener_seq = itertools.count(1) + self.router = Router() + self._listener_metrics = {} + self._setup_exclusion_filters() @property def config_log_level(self): diff --git a/src/hassette/core/core.py b/src/hassette/core/core.py index d534c86ed..0664fdab2 100644 --- a/src/hassette/core/core.py +++ b/src/hassette/core/core.py @@ -74,7 +74,7 @@ def __init__(self, config: HassetteConfig) -> None: self.unique_id = "" enable_logging(self.config.log_level, log_buffer_size=self.config.web_api_log_buffer_size) - super().__init__(self, task_bucket=TaskBucket.create(self, self), parent=self) + super().__init__(self, task_bucket=TaskBucket(self, parent=self), parent=self) self.logger.info("Starting Hassette...", stacklevel=2) # set context variables diff --git a/src/hassette/core/data_sync_service.py b/src/hassette/core/data_sync_service.py index dd3c895a1..40a205ea9 100644 --- a/src/hassette/core/data_sync_service.py +++ b/src/hassette/core/data_sync_service.py @@ -53,16 +53,14 @@ class DataSyncService(Resource): _start_time: float _subscriptions: "list[Subscription]" - @classmethod - def create(cls, hassette: "Hassette", parent: Resource): - inst = cls(hassette=hassette, parent=parent) - inst.bus = inst.add_child(Bus) - inst._event_buffer = deque(maxlen=hassette.config.web_api_event_buffer_size) - inst._ws_clients = set() - inst._lock = asyncio.Lock() - inst._start_time = time.time() - inst._subscriptions = [] - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self.bus = self.add_child(Bus) + self._event_buffer = deque(maxlen=hassette.config.web_api_event_buffer_size) + self._ws_clients: set[asyncio.Queue] = set() + self._lock = asyncio.Lock() + self._start_time = time.time() + self._subscriptions = [] @property def config_log_level(self): diff --git a/src/hassette/core/scheduler_service.py b/src/hassette/core/scheduler_service.py index 66a14aa1b..f34780d66 100644 --- a/src/hassette/core/scheduler_service.py +++ b/src/hassette/core/scheduler_service.py @@ -38,15 +38,12 @@ class SchedulerService(Service): _execution_log: "deque[JobExecutionRecord]" """Ring buffer of recent job execution records.""" - @classmethod - def create(cls, hassette: "Hassette"): - inst = cls(hassette, parent=hassette) - inst._job_queue = inst.add_child(_ScheduledJobQueue) - inst._wakeup_event = asyncio.Event() - inst._exit_event = asyncio.Event() - inst._execution_log = deque(maxlen=hassette.config.web_api_job_history_size) - - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self._job_queue = self.add_child(_ScheduledJobQueue) + self._wakeup_event = asyncio.Event() + self._exit_event = asyncio.Event() + self._execution_log = deque(maxlen=hassette.config.web_api_job_history_size) @property def min_delay(self) -> float: @@ -302,15 +299,11 @@ class _ScheduledJobQueue(Resource): _queue: "HeapQueue[ScheduledJob]" """The heap queue of scheduled jobs.""" - @classmethod - def create(cls, hassette: "Hassette", parent: Resource): - inst = cls(hassette, parent=parent) - inst._lock = FairAsyncRLock() - inst._queue = HeapQueue() - - inst.mark_ready(reason="Queue ready") - - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self._lock = FairAsyncRLock() + self._queue = HeapQueue() + self.mark_ready(reason="Queue ready") @property def config_log_level(self): diff --git a/src/hassette/core/service_watcher.py b/src/hassette/core/service_watcher.py index 6ae8a2699..6fbe5faba 100644 --- a/src/hassette/core/service_watcher.py +++ b/src/hassette/core/service_watcher.py @@ -18,12 +18,10 @@ class ServiceWatcher(Resource): _restart_attempts: dict[str, int] """Tracks restart attempt counts per service, keyed by 'name:role'.""" - @classmethod - def create(cls, hassette: "Hassette"): - inst = cls(hassette, parent=hassette) - inst.bus = inst.add_child(Bus) - inst._restart_attempts = {} - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self.bus = self.add_child(Bus) + self._restart_attempts = {} @property def config_log_level(self): diff --git a/src/hassette/core/state_proxy.py b/src/hassette/core/state_proxy.py index f813ad1a2..902d6761a 100644 --- a/src/hassette/core/state_proxy.py +++ b/src/hassette/core/state_proxy.py @@ -30,26 +30,14 @@ class StateProxy(Resource): state_change_sub: "Subscription | None" poll_job: "ScheduledJob | None" - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - """Create a new StateProxy instance. - - Args: - hassette: The Hassette instance. - parent: The parent resource (typically the Hassette core). - - Returns: - A new StateProxy instance. - """ - inst = cls(hassette=hassette, parent=parent) - inst.states = {} - inst.lock = FairAsyncRLock() - inst.bus = inst.add_child(Bus, priority=100) - inst.scheduler = inst.add_child(Scheduler) - inst.state_change_sub = None - inst.poll_job = None - - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self.states = {} + self.lock = FairAsyncRLock() + self.bus = self.add_child(Bus, priority=100) + self.scheduler = self.add_child(Scheduler) + self.state_change_sub = None + self.poll_job = None @property def config_log_level(self): diff --git a/src/hassette/core/web_api_service.py b/src/hassette/core/web_api_service.py index 5557facd6..40c1db6d3 100644 --- a/src/hassette/core/web_api_service.py +++ b/src/hassette/core/web_api_service.py @@ -20,13 +20,11 @@ class WebApiService(Service): port: int _server: uvicorn.Server | None - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - inst = cls(hassette=hassette, parent=parent) - inst.host = hassette.config.web_api_host - inst.port = hassette.config.web_api_port - inst._server = None - return inst + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self.host = hassette.config.web_api_host + self.port = hassette.config.web_api_port + self._server = None @property def config_log_level(self): diff --git a/src/hassette/core/websocket_service.py b/src/hassette/core/websocket_service.py index 5a548b349..b49614482 100644 --- a/src/hassette/core/websocket_service.py +++ b/src/hassette/core/websocket_service.py @@ -35,6 +35,7 @@ if typing.TYPE_CHECKING: from hassette import Hassette from hassette.events.hass.raw import HassEventEnvelopeDict + from hassette.resources.base import Resource LOGGER = getLogger(__name__) @@ -77,20 +78,17 @@ class WebsocketService(Service): _connect_lock: asyncio.Lock """Lock to prevent concurrent connection attempts.""" - @classmethod - def create(cls, hassette: "Hassette"): - inst = cls(hassette=hassette, parent=hassette) - inst.url = inst.hassette.ws_url - inst._stack = AsyncExitStack() - inst._session = None - inst._ws = None - inst._response_futures = {} - inst._seq = count(1) - - inst._recv_task = None - inst._subscription_ids = set() - inst._connect_lock = asyncio.Lock() # if you don't already have it - return inst + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self.url = self.hassette.ws_url + self._stack = AsyncExitStack() + self._session = None + self._ws = None + self._response_futures = {} + self._seq = count(1) + self._recv_task = None + self._subscription_ids = set() + self._connect_lock = asyncio.Lock() @property def config_log_level(self): diff --git a/src/hassette/resources/base.py b/src/hassette/resources/base.py index 2a3228ab3..490dedf64 100644 --- a/src/hassette/resources/base.py +++ b/src/hassette/resources/base.py @@ -1,5 +1,4 @@ import asyncio -import inspect import typing import uuid from abc import abstractmethod @@ -112,21 +111,6 @@ class Resource(LifecycleMixin, metaclass=FinalMeta): def __init_subclass__(cls) -> None: cls.class_name = cls.__name__ - @classmethod - def create( - cls, hassette: "Hassette", task_bucket: "TaskBucket | None" = None, parent: "Resource | None" = None, **kwargs - ): - sig = inspect.signature(cls) - # Start with a copy of incoming kwargs to preserve any extra arguments - final_kwargs = dict(kwargs) - if "hassette" in sig.parameters: - final_kwargs["hassette"] = hassette - if "task_bucket" in sig.parameters: - final_kwargs["task_bucket"] = task_bucket - if "parent" in sig.parameters: - final_kwargs["parent"] = parent - return cls(**final_kwargs) - def __init__( self, hassette: "Hassette", task_bucket: "TaskBucket | None" = None, parent: "Resource | None" = None ) -> None: @@ -147,7 +131,7 @@ def __init__( # TaskBucket is special: it is its own task bucket self.task_bucket = self else: - self.task_bucket = task_bucket or TaskBucket.create(self.hassette, parent=self) + self.task_bucket = task_bucket or TaskBucket(self.hassette, parent=self) def _get_logger_name(self) -> str: if self.class_name == "Hassette": @@ -224,14 +208,7 @@ def add_child(self, child_class: type[_ResourceT], **kwargs) -> _ResourceT: if "parent" in kwargs: raise ValueError("Cannot specify 'parent' argument when adding a child resource; it is set automatically.") - sig = inspect.signature(child_class.create) - if "parent" in sig.parameters: - kwargs["parent"] = self - - if "hassette" not in sig.parameters: - raise ValueError("Child resource class must accept 'hassette' argument in its create() method.") - - inst = child_class.create(self.hassette, **kwargs) + inst = child_class(hassette=self.hassette, parent=self, **kwargs) self.children.append(inst) return inst diff --git a/src/hassette/scheduler/scheduler.py b/src/hassette/scheduler/scheduler.py index 0658c96fe..97dc86de6 100644 --- a/src/hassette/scheduler/scheduler.py +++ b/src/hassette/scheduler/scheduler.py @@ -131,15 +131,13 @@ class Scheduler(Resource): _jobs_by_name: dict[str, "ScheduledJob"] """Tracks jobs by name for uniqueness validation within this scheduler instance.""" - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - inst = cls(hassette=hassette, parent=parent) - inst.scheduler_service = inst.hassette._scheduler_service - assert inst.scheduler_service is not None, "Scheduler service not initialized" - inst._jobs_by_name = {} - - inst.mark_ready(reason="Scheduler initialized") - return inst + def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self.scheduler_service = self.hassette._scheduler_service + assert self.scheduler_service is not None, "Scheduler service not initialized" + self._jobs_by_name = {} + + self.mark_ready(reason="Scheduler initialized") @property def config_log_level(self): diff --git a/src/hassette/state_manager/state_manager.py b/src/hassette/state_manager/state_manager.py index 6331f8e7e..05e3bec99 100644 --- a/src/hassette/state_manager/state_manager.py +++ b/src/hassette/state_manager/state_manager.py @@ -219,6 +219,10 @@ class StateManager(Resource): _domain_states_cache: dict[type[BaseState], DomainStates[BaseState]] + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self._domain_states_cache = {} + async def after_initialize(self) -> None: self.mark_ready() @@ -227,22 +231,6 @@ def _state_proxy(self) -> StateProxy: """Access the underlying StateProxy instance.""" return self.hassette._state_proxy - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - """Create a new States resource instance. - - Args: - hassette: The Hassette instance. - parent: The parent resource (typically the Hassette core). - - Returns: - A new States resource instance. - """ - inst = cls(hassette=hassette, parent=parent) - inst._domain_states_cache = {} - - return inst - def __getattr__(self, domain: str) -> "DomainStates[BaseState]": """Dynamically access domain states by property name. diff --git a/src/hassette/state_manager/state_manager.pyi b/src/hassette/state_manager/state_manager.pyi index d4e386636..948df4903 100644 --- a/src/hassette/state_manager/state_manager.pyi +++ b/src/hassette/state_manager/state_manager.pyi @@ -58,8 +58,7 @@ class DomainStates(typing.Generic[StateT]): class StateManager(Resource): @property def _state_proxy(self) -> StateProxy: ... - @classmethod - def create(cls, hassette: Hassette, parent: Resource) -> StateManager: ... + def __init__(self, hassette: Hassette, *, parent: Resource | None = None) -> None: ... # Known domain properties with full typing @property diff --git a/src/hassette/task_bucket/task_bucket.py b/src/hassette/task_bucket/task_bucket.py index 08a8d6ede..c5dc4e096 100644 --- a/src/hassette/task_bucket/task_bucket.py +++ b/src/hassette/task_bucket/task_bucket.py @@ -30,14 +30,10 @@ class TaskBucket(Resource): _tasks: "weakref.WeakSet[asyncio.Task[Any]]" """Weak set of tasks tracked by this bucket.""" - @classmethod - def create(cls, hassette: "Hassette", parent: "Resource"): - inst = cls(hassette=hassette, parent=parent) - - inst._tasks = weakref.WeakSet() - - inst.mark_ready(reason="TaskBucket initialized") - return inst + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self._tasks = weakref.WeakSet() + self.mark_ready(reason="TaskBucket initialized") @property def config_cancel_timeout(self) -> int | float: diff --git a/src/hassette/test_utils/harness.py b/src/hassette/test_utils/harness.py index 2cc9abf7b..efacc94e7 100644 --- a/src/hassette/test_utils/harness.py +++ b/src/hassette/test_utils/harness.py @@ -285,7 +285,7 @@ async def start(self) -> "HassetteHarness": self.hassette._loop = asyncio.get_running_loop() self.hassette._loop_thread_id = threading.get_ident() - self.hassette.task_bucket = TaskBucket.create(cast("Hassette", self.hassette), parent=self.hassette) # pyright: ignore[reportArgumentType] + self.hassette.task_bucket = TaskBucket(cast("Hassette", self.hassette), parent=self.hassette) # pyright: ignore[reportArgumentType] self.hassette._loop.set_task_factory(make_task_factory(self.hassette.task_bucket)) # pyright: ignore[reportArgumentType] # Start components in dependency order diff --git a/tests/integration/test_service_watcher.py b/tests/integration/test_service_watcher.py index 35d042646..42bf84a35 100644 --- a/tests/integration/test_service_watcher.py +++ b/tests/integration/test_service_watcher.py @@ -11,7 +11,7 @@ @pytest.fixture async def get_service_watcher_mock(hassette_with_bus): """Return a fresh service watcher for each test.""" - watcher = ServiceWatcher.create(hassette_with_bus) + watcher = ServiceWatcher(hassette_with_bus, parent=hassette_with_bus) original_children = list(hassette_with_bus.children) with preserve_config(hassette_with_bus.config): yield watcher diff --git a/tests/integration/test_state_proxy.py b/tests/integration/test_state_proxy.py index bf9daaedc..3d41570b6 100644 --- a/tests/integration/test_state_proxy.py +++ b/tests/integration/test_state_proxy.py @@ -120,7 +120,7 @@ def state_proxy(): mock_hassette.config.log_level = "DEBUG" mock_hassette.config.bus_service_log_level = "DEBUG" - proxy = StateProxy.create(mock_hassette, mock_hassette) + proxy = StateProxy(mock_hassette, parent=mock_hassette) proxy.mark_ready(reason="Test setup") return proxy diff --git a/tests/integration/test_states.py b/tests/integration/test_states.py index 3f26702a5..e102b16d8 100644 --- a/tests/integration/test_states.py +++ b/tests/integration/test_states.py @@ -55,7 +55,7 @@ async def test_lights_returns_light_states(self, hassette_with_state_proxy: "Has await _send_and_wait(hassette, entity_id, None, state_dict) # Create StateManager instance and access lights - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) lights = states_instance.light assert isinstance(lights, DomainStates) @@ -87,7 +87,7 @@ async def test_sensors_returns_sensor_states(self, hassette_with_state_proxy: "H for entity_id, state_dict in [("sensor.temperature", sensor1), ("sensor.humidity", sensor2)]: await _send_and_wait(hassette, entity_id, None, state_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) sensors = states_instance.sensor sensor_ids = [] @@ -112,7 +112,7 @@ async def test_switches_returns_switch_states(self, hassette_with_state_proxy: " for entity_id, state_dict in [("switch.outlet1", switch1), ("switch.outlet2", switch2)]: await _send_and_wait(hassette, entity_id, None, state_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) switches = states_instance.switch switch_ids = [] @@ -138,7 +138,7 @@ async def test_get_states_with_model(self, hassette_with_state_proxy: "Hassette" light = make_light_state_dict("light.test", "on", brightness=200) await _send_and_wait(hassette, "light.test", None, light) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) lights = states_instance[states.LightState] assert isinstance(lights, DomainStates) @@ -165,7 +165,7 @@ async def test_value_with_decimals_does_not_lose_precision(self, hassette_with_s new_sensor_dict = make_state_dict("input_number.test_value", "22.5") await _send_and_wait(hassette, "input_number.test_value", old_sensor_dict, new_sensor_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) assert states_instance.input_number.get("input_number.test_value").value == 22.5 @@ -181,7 +181,7 @@ async def test_states_are_cached_until_changed(self, hassette_with_state_proxy: new_state_dict = make_state_dict("input_number.test_value", "22.5") await _send_and_wait(hassette, "input_number.test_value", old_state_dict, new_state_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) input_number_manager = states_instance.input_number orig_obj = input_number_manager.get("input_number.test_value") @@ -215,7 +215,7 @@ async def test_iteration_over_domain(self, hassette_with_state_proxy: "Hassette" light = make_light_state_dict(f"light.room_{i}", "on") await _send_and_wait(hassette, f"light.room_{i}", None, light) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) lights = states_instance.light # Iterate and collect @@ -238,7 +238,7 @@ async def test_len_of_domain(self, hassette_with_state_proxy: "Hassette") -> Non for entity_id, state_dict in [("sensor.test_1", sensor1), ("sensor.test_2", sensor2)]: await _send_and_wait(hassette, entity_id, None, state_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) sensors = states_instance.sensor assert len(sensors) >= 2 @@ -251,7 +251,7 @@ async def test_get_with_matching_domain(self, hassette_with_state_proxy: "Hasset light = make_light_state_dict("light.test", "on", brightness=100) await _send_and_wait(hassette, "light.test", None, light) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) lights = states_instance.light result = lights.get("light.test") @@ -270,7 +270,7 @@ async def test_get_with_wrong_domain_raises_value_error(self, hassette_with_stat sensor = make_sensor_state_dict("sensor.test", "25") await _send_and_wait(hassette, "sensor.test", None, sensor) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) # Try to get sensor from lights domain lights = states_instance.light @@ -281,7 +281,7 @@ async def test_iteration_over_empty_domain(self, hassette_with_state_proxy: "Has """Iterating over DomainStates with no entities returns empty.""" hassette = hassette_with_state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) # Assuming no climate entities exist climate_states = states_instance.climate @@ -298,7 +298,7 @@ async def test_proxy_stores_base_states_accessors_convert(self, hassette_with_st """StateProxy stores BaseState, States accessors convert to domain-specific types.""" hassette = hassette_with_state_proxy proxy = hassette._state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) # Add various entity types light = make_light_state_dict("light.test", "on", brightness=150) @@ -342,7 +342,7 @@ async def test_states_reflects_proxy_updates(self, hassette_with_state_proxy: "H """States accessors reflect live updates from StateProxy.""" hassette = hassette_with_state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) light_manager = states_instance.light @@ -370,7 +370,7 @@ async def test_domain_filtering_across_updates(self, hassette_with_state_proxy: """Domain accessors correctly filter across multiple updates.""" hassette = hassette_with_state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) # Add multiple entity types entities = [ @@ -430,7 +430,7 @@ async def test_lazy_iteration_with_caching(self, hassette_with_state_proxy: "Has for entity_id, state_dict in entities: await _send_and_wait(hassette, entity_id, None, state_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) light_manager = states_instance.light @@ -457,7 +457,7 @@ async def test_get_registered_domain_returns_typed_state(self, hassette_with_sta light_dict = make_light_state_dict("light.bedroom", "on", brightness=150) await _send_and_wait(hassette, "light.bedroom", None, light_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) result = states_instance.get("light.bedroom") assert result is not None @@ -474,7 +474,7 @@ async def test_get_unregistered_domain_returns_base_state(self, hassette_with_st test_dict = make_state_dict("test.test_entity", "test_value") await _send_and_wait(hassette, "test.test_entity", None, test_dict) - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) result = states_instance.get("test.test_entity") assert result is not None @@ -487,7 +487,7 @@ async def test_get_unregistered_domain_returns_base_state(self, hassette_with_st async def test_get_nonexistent_entity_returns_none(self, hassette_with_state_proxy: "Hassette") -> None: """get() returns None for entities that don't exist.""" hassette = hassette_with_state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) result = states_instance.get("nonexistent.entity") assert result is None @@ -495,7 +495,7 @@ async def test_get_nonexistent_entity_returns_none(self, hassette_with_state_pro async def test_get_with_invalid_entity_id_returns_none(self, hassette_with_state_proxy: "Hassette") -> None: """get() returns None for malformed entity IDs.""" hassette = hassette_with_state_proxy - states_instance = StateManager.create(hassette, hassette) + states_instance = StateManager(hassette, parent=hassette) result = states_instance.get("invalid_no_dot") assert result is None diff --git a/tests/integration/test_websocket_service.py b/tests/integration/test_websocket_service.py index c9c42edc7..68daadd2d 100644 --- a/tests/integration/test_websocket_service.py +++ b/tests/integration/test_websocket_service.py @@ -24,7 +24,7 @@ @pytest.fixture def websocket_service(hassette_with_bus: "Hassette") -> WebsocketService: """Create a fresh websocket service instance for each test.""" - return WebsocketService.create(hassette_with_bus) + return WebsocketService(hassette_with_bus, parent=hassette_with_bus) def _build_fake_ws(*, is_closed: bool = False) -> ClientWebSocketResponse: diff --git a/tests/unit/test_app_factory.py b/tests/unit/test_app_factory.py index b399ba7d4..bfee61612 100644 --- a/tests/unit/test_app_factory.py +++ b/tests/unit/test_app_factory.py @@ -73,8 +73,8 @@ def test_create_instances_success_single_config( factory.create_instances("test_app", mock_manifest) - mock_app_class.create.assert_called_once() - mock_registry.register_app.assert_called_once_with("test_app", 0, mock_app_class.create.return_value) + mock_app_class.assert_called_once() + mock_registry.register_app.assert_called_once_with("test_app", 0, mock_app_class.return_value) @patch("hassette.core.app_factory.load_app_class_from_manifest") def test_create_instances_success_multiple_configs( @@ -89,10 +89,10 @@ def test_create_instances_success_multiple_configs( factory.create_instances("test_app", mock_manifest) - assert mock_app_class.create.call_count == 2 + assert mock_app_class.call_count == 2 assert mock_registry.register_app.call_count == 2 - mock_registry.register_app.assert_any_call("test_app", 0, mock_app_class.create.return_value) - mock_registry.register_app.assert_any_call("test_app", 1, mock_app_class.create.return_value) + mock_registry.register_app.assert_any_call("test_app", 0, mock_app_class.return_value) + mock_registry.register_app.assert_any_call("test_app", 1, mock_app_class.return_value) @patch("hassette.core.app_factory.load_app_class_from_manifest") def test_create_instances_empty_config( @@ -104,7 +104,7 @@ def test_create_instances_empty_config( factory.create_instances("test_app", mock_manifest) - mock_app_class.create.assert_not_called() + mock_app_class.assert_not_called() mock_registry.register_app.assert_not_called() @patch("hassette.core.app_factory.class_failed_to_load", return_value=True) @@ -142,7 +142,7 @@ def test_create_instances_missing_instance_name( assert isinstance(call_args[0][2], ValueError) # Second config should succeed - mock_registry.register_app.assert_called_once_with("test_app", 1, mock_app_class.create.return_value) + mock_registry.register_app.assert_called_once_with("test_app", 1, mock_app_class.return_value) @patch("hassette.core.app_factory.load_app_class_from_manifest") def test_create_instances_validation_failure( @@ -164,11 +164,11 @@ def test_create_instances_validation_failure( def test_create_instances_app_create_failure( self, mock_load_class, factory: AppFactory, mock_registry: AppRegistry, mock_manifest ): - """Records failure when App.create() raises exception.""" + """Records failure when App() constructor raises exception.""" create_error = RuntimeError("Create failed") mock_app_class = Mock(__name__="TestApp") - mock_app_class.create.side_effect = create_error + mock_app_class.side_effect = create_error mock_load_class.return_value = mock_app_class factory.create_instances("test_app", mock_manifest) From 46464d9113972909fff370eea911ab50be684959 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 24 Feb 2026 14:02:01 -0600 Subject: [PATCH 2/3] fix: update sync facade generator to emit __init__ instead of create() --- tools/generate_sync_facade.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tools/generate_sync_facade.py b/tools/generate_sync_facade.py index a6876e31f..fdc835160 100755 --- a/tools/generate_sync_facade.py +++ b/tools/generate_sync_facade.py @@ -52,12 +52,10 @@ class ApiSyncFacade(Resource): _api: "Api" - @classmethod - def create(cls, hassette: "Hassette", api: "Api"): - inst = cls(hassette, parent=api) - inst._api = api - inst.mark_ready(reason="Synchronous API facade initialized") - return inst + def __init__(self, hassette: "Hassette", *, api: "Api", parent: Resource | None = None) -> None: + super().__init__(hassette, parent=parent) + self._api = api + self.mark_ready(reason="Synchronous API facade initialized") ''' From 31404c65688c2e0c235468c902a35814852da73b Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 24 Feb 2026 14:06:23 -0600 Subject: [PATCH 3/3] refactor: convert DatabaseService.create() to __init__ constructor Applies the same create()-to-__init__() conversion to DatabaseService, which was added on main in #305 after this branch diverged. --- src/hassette/core/database_service.py | 17 ++++++++--------- tests/integration/test_database_service.py | 2 +- tests/unit/core/test_database_service.py | 8 ++++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/hassette/core/database_service.py b/src/hassette/core/database_service.py index 52b8ede80..66722bce7 100644 --- a/src/hassette/core/database_service.py +++ b/src/hassette/core/database_service.py @@ -14,6 +14,7 @@ if typing.TYPE_CHECKING: from hassette import Hassette + from hassette.resources.base import Resource # Heartbeat interval: 5 minutes _HEARTBEAT_INTERVAL_SECONDS = 300 @@ -50,15 +51,13 @@ class DatabaseService(Service): _session_error: bool """Whether a service crash has been recorded for this session.""" - @classmethod - def create(cls, hassette: "Hassette") -> "DatabaseService": - inst = cls(hassette, parent=hassette) - inst._db = None - inst._session_id = None - inst._db_path = Path() - inst._consecutive_heartbeat_failures = 0 - inst._session_error = False - return inst + def __init__(self, hassette: "Hassette", *, parent: "Resource | None" = None) -> None: + super().__init__(hassette, parent=parent) + self._db = None + self._session_id = None + self._db_path = Path() + self._consecutive_heartbeat_failures = 0 + self._session_error = False @property def config_log_level(self) -> str: diff --git a/tests/integration/test_database_service.py b/tests/integration/test_database_service.py index 301e34493..cf3b6478e 100644 --- a/tests/integration/test_database_service.py +++ b/tests/integration/test_database_service.py @@ -36,7 +36,7 @@ def mock_hassette(tmp_path: Path) -> MagicMock: @pytest.fixture def service(mock_hassette: MagicMock) -> DatabaseService: """Create a DatabaseService instance (not yet initialized).""" - return DatabaseService.create(mock_hassette) + return DatabaseService(mock_hassette, parent=mock_hassette) @pytest.fixture diff --git a/tests/unit/core/test_database_service.py b/tests/unit/core/test_database_service.py index d9cdd740a..38c448f16 100644 --- a/tests/unit/core/test_database_service.py +++ b/tests/unit/core/test_database_service.py @@ -27,12 +27,12 @@ def mock_hassette(tmp_path: Path) -> MagicMock: @pytest.fixture def service(mock_hassette: MagicMock) -> DatabaseService: - """Create a DatabaseService instance using the factory.""" - return DatabaseService.create(mock_hassette) + """Create a DatabaseService instance.""" + return DatabaseService(mock_hassette, parent=mock_hassette) -def test_create_sets_defaults(service: DatabaseService) -> None: - """Factory sets _db, _session_id, _db_path, failure counter, and session error flag to initial values.""" +def test_init_sets_defaults(service: DatabaseService) -> None: + """Constructor sets _db, _session_id, _db_path, failure counter, and session error flag to initial values.""" assert service._db is None assert service._session_id is None assert service._db_path == Path()