Skip to content

Commit 2fae50e

Browse files
authored
Feature/clean up and add cache (#194)
* clean up hook methods slightly * move private attributes to top * add basic cache using diskcache * ensure cache is closed * add docstring * finally get rid of false errors in tests by not initializing test apps if in pytest * lazy load cache, handle exception when closing * remove some old crap from test files * no longer need this * move cancel and cache closing to cleanup method, improve base resource cleanup method with timeout, add new timeout config * add back .env file, put something more inocuous in there * update changelog * add a fake token to test config class * use cached_property instead * bring back private attribute, use for testing existence when closing * update docstring and changelog entry
1 parent 27fd6a3 commit 2fae50e

File tree

15 files changed

+127
-68
lines changed

15 files changed

+127
-68
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
- Add `diskcache` dependency and `cache` attribute to all resources
12+
- Each resource class has its own cache directory under the Hassette data directory
13+
1014
## [0.16.0] - 2025-11-16
1115

1216
### Added

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies = [
3838
"coloredlogs>=15.0.1",
3939
"croniter>=6.0.0",
4040
"deepdiff>=8.6.1",
41+
"diskcache>=5.6.3",
4142
"fair-async-rlock>=1.0.7",
4243
"glom>=24.11.0",
4344
"humanize>=4.13.0",

src/hassette/app/app.py

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import asyncio
22
import logging
33
import typing
4-
from contextlib import suppress
54
from logging import getLogger
65
from typing import Any, ClassVar, Generic, final
76

@@ -67,6 +66,9 @@ class App(Generic[AppConfigT], Resource, metaclass=AppMeta):
6766
_only_app: ClassVar[bool] = False
6867
"""If True, only this app will be run. Only one app can be marked as only."""
6968

69+
_import_exception: ClassVar[Exception | None] = None
70+
"""Exception raised during import, if any. This prevents having all apps in a module fail due to one exception."""
71+
7072
role: ClassVar[ResourceRole] = ResourceRole.APP
7173
"""Role of the resource, e.g. 'App', 'Service', etc."""
7274

@@ -77,9 +79,6 @@ class App(Generic[AppConfigT], Resource, metaclass=AppMeta):
7779
"""Config class to use for instances of the created app. Configuration from hassette.toml or
7880
other sources will be validated by this class."""
7981

80-
_import_exception: ClassVar[Exception | None] = None
81-
"""Exception raised during import, if any. This prevents having all apps in a module fail due to one exception."""
82-
8382
logger: logging.Logger
8483
"""Logger for the instance."""
8584

@@ -147,10 +146,9 @@ async def cleanup(self, timeout: int | None = None) -> None:
147146
148147
This method is called during shutdown to ensure that all resources are properly released.
149148
"""
150-
self.cancel()
151-
with suppress(asyncio.CancelledError):
152-
if self._init_task:
153-
await asyncio.wait_for(self._init_task, timeout=timeout)
149+
timeout = timeout or self.hassette.config.app_shutdown_timeout_seconds
150+
151+
await super().cleanup(timeout=timeout)
154152

155153
tasks = []
156154

@@ -167,69 +165,65 @@ async def cleanup(self, timeout: int | None = None) -> None:
167165

168166

169167
class AppSync(App[AppConfigT]):
170-
"""Synchronous adapter for App.
171-
172-
This class allows synchronous apps to work properly in the async environment
173-
by using anyio's thread management capabilities.
174-
"""
168+
"""Synchronous adapter for App."""
175169

176170
def send_event_sync(self, event_name: str, event: Event[Any]) -> None:
177171
"""Synchronous version of send_event."""
178172
self.task_bucket.run_sync(self.send_event(event_name, event))
179173

180-
# --- developer-facing hooks (override as needed) -------------------
174+
@final
181175
async def before_shutdown(self) -> None:
182176
"""Optional: stop accepting new work, signal loops to wind down, etc."""
183177
await self.task_bucket.run_in_thread(self.before_shutdown_sync)
184178

179+
@final
185180
async def on_shutdown(self) -> None:
186181
"""Primary hook: release your own stuff (sockets, queues, temp files…)."""
187182
await self.task_bucket.run_in_thread(self.on_shutdown_sync)
188-
await super().on_shutdown()
189183

184+
@final
190185
async def after_shutdown(self) -> None:
191186
"""Optional: last-chance actions after on_shutdown, before cleanup/STOPPED."""
192187
await self.task_bucket.run_in_thread(self.after_shutdown_sync)
193188

194-
# --- developer-facing hooks (override as needed) -------------------
189+
@final
195190
async def before_initialize(self) -> None:
196191
"""Optional: prepare to accept new work, allocate sockets, queues, temp files, etc."""
197192
await self.task_bucket.run_in_thread(self.before_initialize_sync)
198193

194+
@final
199195
async def on_initialize(self) -> None:
200196
"""Primary hook: perform your own initialization (sockets, queues, temp files…)."""
201197
await self.task_bucket.run_in_thread(self.on_initialize_sync)
202198

199+
@final
203200
async def after_initialize(self) -> None:
204201
"""Optional: finalize initialization, signal readiness, etc."""
205202
await self.task_bucket.run_in_thread(self.after_initialize_sync)
206203

207-
# --- developer-facing hooks (override as needed) -------------------
208204
def before_shutdown_sync(self) -> None:
209205
"""Optional: stop accepting new work, signal loops to wind down, etc."""
210-
# Default: cancel an in-flight initialize() task if you used Resource.start()
211-
self.cancel()
206+
pass
212207

213208
def on_shutdown_sync(self) -> None:
214209
"""Primary hook: release your own stuff (sockets, queues, temp files…)."""
215-
# Default: nothing. Subclasses override when they own resources.
210+
pass
216211

217212
def after_shutdown_sync(self) -> None:
218213
"""Optional: last-chance actions after on_shutdown, before cleanup/STOPPED."""
219-
# Default: nothing.
214+
pass
220215

221-
# --- developer-facing hooks (override as needed) -------------------
222216
def before_initialize_sync(self) -> None:
223217
"""Optional: prepare to accept new work, allocate sockets, queues, temp files, etc."""
224-
# Default: nothing. Subclasses override when they own resources.
218+
pass
225219

226220
def on_initialize_sync(self) -> None:
227221
"""Primary hook: perform your own initialization (sockets, queues, temp files…)."""
228-
# Default: nothing. Subclasses override when they own resources.
222+
pass
229223

230224
def after_initialize_sync(self) -> None:
231225
"""Optional: finalize initialization, signal readiness, etc."""
232-
# Default: nothing. Subclasses override when they own resources.
226+
pass
233227

234228
@final
235229
def initialize_sync(self) -> None:

src/hassette/config/core.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ def settings_customise_sources(
153153
app_shutdown_timeout_seconds: int = Field(default=10)
154154
"""Length of time to wait for an app to shut down before giving up."""
155155

156+
resource_shutdown_timeout_seconds: int = Field(
157+
default_factory=lambda data: data.get("app_shutdown_timeout_seconds", 10)
158+
)
159+
"""Length of time to wait for a resource to shut down before giving up. Defaults to app_shutdown_timeout_seconds."""
160+
156161
websocket_authentication_timeout_seconds: int = Field(default=10)
157162
"""Length of time to wait for WebSocket authentication to complete."""
158163

@@ -198,6 +203,9 @@ def settings_customise_sources(
198203
task_cancellation_timeout_seconds: int = Field(default=5)
199204
"""Length of time to wait for tasks to cancel before forcing."""
200205

206+
default_cache_size: int = Field(default=100 * 1024 * 1024)
207+
"""Default size limit for caches in bytes. Defaults to 100 MiB."""
208+
201209
# Service log levels
202210

203211
bus_service_log_level: LOG_ANNOTATION = Field(default_factory=log_level_default_factory)

src/hassette/config/hassette.dev.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ allow_startup_if_app_precheck_fails = true
1111
startup_timeout_seconds = 20
1212
app_startup_timeout_seconds = 40
1313
app_shutdown_timeout_seconds = 20
14+
resource_shutdown_timeout_seconds = 20
1415
websocket_authentication_timeout_seconds = 20
1516
websocket_response_timeout_seconds = 10
1617
websocket_connection_timeout_seconds = 10
@@ -40,3 +41,4 @@ log_all_hass_events = false
4041
log_all_hassette_events = false
4142
allow_reload_in_prod = false
4243
allow_only_app_in_prod = false
44+
default_cache_size = 104857600 # 100 MiB

src/hassette/config/hassette.prod.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ allow_startup_if_app_precheck_fails = false
1111
startup_timeout_seconds = 10
1212
app_startup_timeout_seconds = 20
1313
app_shutdown_timeout_seconds = 10
14+
resource_shutdown_timeout_seconds = 10
1415
websocket_authentication_timeout_seconds = 10
1516
websocket_response_timeout_seconds = 5
1617
websocket_connection_timeout_seconds = 5
@@ -40,3 +41,4 @@ log_all_hass_events = false
4041
log_all_hassette_events = false
4142
allow_reload_in_prod = false
4243
allow_only_app_in_prod = false
44+
default_cache_size = 104857600 # 100 MiB

src/hassette/resources/base.py

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
from abc import abstractmethod
66
from collections.abc import Coroutine
77
from contextlib import suppress
8+
from functools import cached_property
89
from logging import Logger, getLogger
910
from typing import Any, ClassVar, TypeVar, final
1011

12+
from diskcache import Cache
13+
1114
from hassette.exceptions import CannotOverrideFinalError, FatalError
1215
from hassette.types.enums import ResourceRole
1316

@@ -66,6 +69,18 @@ def __init__(cls, name, bases, ns, **kw):
6669
class Resource(LifecycleMixin, metaclass=FinalMeta):
6770
"""Base class for resources in the Hassette framework."""
6871

72+
_shutting_down: bool = False
73+
"""Flag indicating whether the instance is in the process of shutting down."""
74+
75+
_initializing: bool = False
76+
"""Flag indicating whether the instance is in the process of starting up."""
77+
78+
_unique_name: str
79+
"""Unique name for the instance."""
80+
81+
_cache: Cache | None
82+
"""Private attribute to hold the cache, to allow lazy initialization."""
83+
6984
role: ClassVar[ResourceRole] = ResourceRole.RESOURCE
7085
"""Role of the resource, e.g. 'App', 'Service', etc."""
7186

@@ -78,18 +93,9 @@ class Resource(LifecycleMixin, metaclass=FinalMeta):
7893
children: list["Resource"]
7994
"""List of child resources."""
8095

81-
_shutting_down: bool = False
82-
"""Flag indicating whether the instance is in the process of shutting down."""
83-
84-
_initializing: bool = False
85-
"""Flag indicating whether the instance is in the process of starting up."""
86-
8796
logger: Logger
8897
"""Logger for the instance."""
8998

90-
_unique_name: str
91-
"""Unique name for the instance."""
92-
9399
unique_id: str
94100
"""Unique identifier for the instance."""
95101

@@ -124,6 +130,7 @@ def __init__(
124130

125131
super().__init__()
126132

133+
self._cache = None # lazy init
127134
self.unique_id = uuid.uuid4().hex[:8]
128135

129136
self.hassette = hassette
@@ -152,6 +159,18 @@ def _setup_logger(self) -> None:
152159
def __repr__(self) -> str:
153160
return f"<{type(self).__name__} unique_name={self.unique_name}>"
154161

162+
@cached_property
163+
def cache(self) -> Cache:
164+
"""Disk cache for storing arbitrary data. All instances of the same resource class share a cache directory."""
165+
if self._cache is not None:
166+
return self._cache
167+
168+
# set up cache
169+
cache_dir = self.hassette.config.data_dir.joinpath(self.class_name).joinpath("cache")
170+
cache_dir.mkdir(parents=True, exist_ok=True)
171+
self._cache = Cache(cache_dir, size_limit=self.hassette.config.default_cache_size)
172+
return self._cache
173+
155174
@property
156175
def unique_name(self) -> str:
157176
"""Get the unique name of the instance."""
@@ -199,19 +218,6 @@ def add_child(self, child_class: type[_ResourceT], **kwargs) -> _ResourceT:
199218
self.children.append(inst)
200219
return inst
201220

202-
# --- developer-facing hooks (override as needed) -------------------
203-
async def before_initialize(self) -> None:
204-
"""Optional: prepare to accept new work, allocate sockets, queues, temp files, etc."""
205-
# Default: nothing. Subclasses override when they own resources.
206-
207-
async def on_initialize(self) -> None:
208-
"""Primary hook: perform your own initialization (sockets, queues, temp files…)."""
209-
# Default: nothing. Subclasses override when they own resources.
210-
211-
async def after_initialize(self) -> None:
212-
"""Optional: finalize initialization, signal readiness, etc."""
213-
# Default: nothing. Subclasses override when they own resources.
214-
215221
@final
216222
async def initialize(self) -> None:
217223
"""Initialize the instance by calling the lifecycle hooks in order."""
@@ -244,19 +250,17 @@ async def initialize(self) -> None:
244250
finally:
245251
self._initializing = False
246252

247-
# --- developer-facing hooks (override as needed) -------------------
248-
async def before_shutdown(self) -> None:
249-
"""Optional: stop accepting new work, signal loops to wind down, etc."""
250-
# Default: cancel an in-flight initialize() task if you used Resource.start()
251-
self.cancel()
253+
async def before_initialize(self) -> None:
254+
"""Optional: prepare to accept new work, allocate sockets, queues, temp files, etc."""
255+
pass
252256

253-
async def on_shutdown(self) -> None:
254-
"""Primary hook: release your own stuff (sockets, queues, temp files…)."""
255-
# Default: nothing. Subclasses override when they own resources.
257+
async def on_initialize(self) -> None:
258+
"""Primary hook: perform your own initialization (sockets, queues, temp files…)."""
259+
pass
256260

257-
async def after_shutdown(self) -> None:
258-
"""Optional: last-chance actions after on_shutdown, before cleanup/STOPPED."""
259-
# Default: nothing.
261+
async def after_initialize(self) -> None:
262+
"""Optional: finalize initialization, signal readiness, etc."""
263+
pass
260264

261265
@final
262266
async def shutdown(self) -> None:
@@ -302,21 +306,45 @@ async def shutdown(self) -> None:
302306

303307
self._shutting_down = False
304308

309+
async def before_shutdown(self) -> None:
310+
"""Optional: stop accepting new work, signal loops to wind down, etc."""
311+
pass
312+
313+
async def on_shutdown(self) -> None:
314+
"""Primary hook: release your own stuff (sockets, queues, temp files…)."""
315+
pass
316+
317+
async def after_shutdown(self) -> None:
318+
"""Optional: last-chance actions after on_shutdown, before cleanup/STOPPED."""
319+
pass
320+
305321
async def restart(self) -> None:
306322
"""Restart the instance by shutting it down and re-initializing it."""
307323
self.logger.debug("Restarting '%s' %s", self.class_name, self.role)
308324
await self.shutdown()
309325
await self.initialize()
310326

311-
async def cleanup(self) -> None:
327+
async def cleanup(self, timeout: int | None = None) -> None:
312328
"""Cleanup resources owned by the instance.
313329
314330
This method is called during shutdown to ensure that all resources are properly released.
315331
"""
332+
timeout = timeout or self.hassette.config.resource_shutdown_timeout_seconds
333+
316334
self.cancel()
335+
with suppress(asyncio.CancelledError):
336+
if self._init_task:
337+
await asyncio.wait_for(self._init_task, timeout=timeout)
338+
317339
await self.task_bucket.cancel_all()
318340
self.logger.debug("Cleaned up resources")
319341

342+
if self._cache is not None:
343+
try:
344+
self.cache.close()
345+
except Exception as e:
346+
self.logger.exception("Error closing cache: %s %s", type(e).__name__, e)
347+
320348

321349
class Service(Resource):
322350
"""Base class for services in the Hassette framework."""

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class TestConfig(HassetteConfig):
3535
"env_file": ENV_FILE,
3636
}
3737

38+
token: str = "test-token"
39+
3840
websocket_connection_timeout_seconds: int | float = 1
3941
websocket_authentication_timeout_seconds: int | float = 1
4042
websocket_total_timeout_seconds: int | float = 2

tests/data/.env

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
hassette__token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmNjlhYTIxMDlhNjU0MGU3YWZiYzdjZDJlN2U0ODIwOSIsImlhdCI6MTc1MzIzNzc2MiwiZXhwIjoyMDY4NTk3NzYyfQ.Q-V4TOPp9dVb_S9kTi1OAWQoe0DG0AIWQIsNwEAJ3Fg
2-
hassette__base_url=http://127.0.0.1:8123
3-
hassette__apps__myfakeapp__config__value=42
1+
hassette__apps_log_level=CRITICAL

tests/data/hassette.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
[hassette]
22
base_url = "http://127.0.0.1:8123"
33
app_dir = "tests/data"
4-
5-
secrets = ["my_secret"]

0 commit comments

Comments
 (0)