Skip to content

Commit a3ed4d9

Browse files
authored
Merge pull request #343 from plugwise/improve-cache-writing
Improve writing of cache-files
2 parents d899dd1 + 825b676 commit a3ed4d9

File tree

4 files changed

+68
-21
lines changed

4 files changed

+68
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- PR [337](https://github.com/plugwise/python-plugwise-usb/pull/337): Improve node removal, remove and reset the node as executed by Source, and remove the cache-file.
66
- PR [342](https://github.com/plugwise/python-plugwise-usb/pull/342): Improve node_type chaching.
7+
- PR [343](https://github.com/plugwise/python-plugwise-usb/pull/343): Improve writing of cache-files.
78

89
## 0.46.0 - 2025-09-12
910

plugwise_usb/helpers/cache.py

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from __future__ import annotations
44

55
from asyncio import get_running_loop
6+
from contextlib import suppress
67
import logging
7-
from os import getenv as os_getenv, name as os_name
8+
from os import getenv as os_getenv, getpid as os_getpid, name as os_name
89
from os.path import expanduser as os_path_expand_user, join as os_path_join
10+
from pathlib import Path
11+
from secrets import token_hex as secrets_token_hex
912

1013
from aiofiles import open as aiofiles_open, ospath # type: ignore[import-untyped]
1114
from aiofiles.os import ( # type: ignore[import-untyped]
@@ -54,7 +57,7 @@ async def initialize_cache(self, create_root_folder: bool = False) -> None:
5457
if self._root_dir != "":
5558
if not create_root_folder and not await ospath.exists(self._root_dir):
5659
raise CacheError(
57-
f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists."
60+
f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exist."
5861
)
5962
cache_dir = self._root_dir
6063
else:
@@ -79,8 +82,8 @@ def _get_writable_os_dir(self) -> str:
7982
)
8083
return os_path_join(os_path_expand_user("~"), CACHE_DIR)
8184

82-
async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None:
83-
"""Save information to cache file."""
85+
async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None: # noqa: PLR0912
86+
"""Save information to cache file atomically using aiofiles + temp file."""
8487
if not self._initialized:
8588
raise CacheError(
8689
f"Unable to save cache. Initialize cache file '{self._file_name}' first."
@@ -89,50 +92,87 @@ async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None
8992
current_data: dict[str, str] = {}
9093
if not rewrite:
9194
current_data = await self.read_cache()
92-
processed_keys: list[str] = []
95+
96+
processed_keys: set[str] = set()
9397
data_to_write: list[str] = []
98+
99+
# Prepare data exactly as in original implementation
94100
for _cur_key, _cur_val in current_data.items():
95101
_write_val = _cur_val
96102
if _cur_key in data:
97103
_write_val = data[_cur_key]
98-
processed_keys.append(_cur_key)
104+
processed_keys.add(_cur_key)
99105
data_to_write.append(f"{_cur_key}{CACHE_KEY_SEPARATOR}{_write_val}\n")
106+
100107
# Write remaining new data
101108
for _key, _value in data.items():
102109
if _key not in processed_keys:
103110
data_to_write.append(f"{_key}{CACHE_KEY_SEPARATOR}{_value}\n")
104111

112+
# Atomic write using aiofiles with temporary file
113+
if self._cache_file is None:
114+
raise CacheError("Unable to save cache, cache-file has no name")
115+
116+
cache_file_path = Path(self._cache_file)
117+
temp_path = cache_file_path.with_name(
118+
f".{cache_file_path.name}.tmp.{os_getpid()}.{secrets_token_hex(8)}"
119+
)
120+
105121
try:
122+
# Write to temporary file using aiofiles
106123
async with aiofiles_open(
107-
file=self._cache_file,
124+
file=str(temp_path),
108125
mode="w",
109126
encoding=UTF8,
110-
) as file_data:
111-
await file_data.writelines(data_to_write)
112-
except OSError as exc:
113-
_LOGGER.warning(
114-
"%s while writing data to cache file %s", exc, str(self._cache_file)
115-
)
116-
else:
127+
newline="\n",
128+
) as temp_file:
129+
await temp_file.writelines(data_to_write)
130+
# Ensure buffered data is written
131+
await temp_file.flush()
132+
133+
# Atomic rename (overwrites atomically on all platforms)
134+
temp_path.replace(cache_file_path)
135+
temp_path = None # Successfully renamed
136+
117137
if not self._cache_file_exists:
118138
self._cache_file_exists = True
139+
119140
_LOGGER.debug(
120-
"Saved %s lines to cache file %s", str(len(data)), self._cache_file
141+
"Saved %s lines to cache file %s (aiofiles atomic write)",
142+
len(data_to_write),
143+
self._cache_file,
121144
)
122145

146+
except OSError as exc:
147+
_LOGGER.warning(
148+
"%s while writing data to cache file %s (aiofiles atomic write)",
149+
exc,
150+
str(self._cache_file),
151+
)
152+
finally:
153+
# Cleanup on error
154+
if temp_path and temp_path.exists():
155+
with suppress(OSError):
156+
temp_path.unlink()
157+
123158
async def read_cache(self) -> dict[str, str]:
124159
"""Return current data from cache file."""
125160
if not self._initialized:
126161
raise CacheError(
127-
f"Unable to save cache. Initialize cache file '{self._file_name}' first."
162+
f"Unable to read cache. Initialize cache file '{self._file_name}' first."
128163
)
129164
current_data: dict[str, str] = {}
165+
if self._cache_file is None:
166+
_LOGGER.debug("Cache file has no name, return empty cache data")
167+
return current_data
168+
130169
if not self._cache_file_exists:
131170
_LOGGER.debug(
132-
"Cache file '%s' does not exists, return empty cache data",
171+
"Cache file '%s' does not exist, return empty cache data",
133172
self._cache_file,
134173
)
135174
return current_data
175+
136176
try:
137177
async with aiofiles_open(
138178
file=self._cache_file,
@@ -155,8 +195,10 @@ async def read_cache(self) -> dict[str, str]:
155195
data,
156196
str(self._cache_file),
157197
)
158-
break
198+
continue
199+
159200
current_data[data[:index_separator]] = data[index_separator + 1 :]
201+
160202
return current_data
161203

162204
async def delete_cache(self) -> None:

plugwise_usb/network/cache.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ async def save_cache(self) -> None:
3030
mac: node_type.name for mac, node_type in self._nodetypes.items()
3131
}
3232
_LOGGER.debug("Save NodeTypes for %s Nodes", len(cache_data_to_save))
33-
await self.write_cache(cache_data_to_save, rewrite=True) # Make sure the cache-contents is actual
33+
await self.write_cache(
34+
cache_data_to_save, rewrite=True
35+
) # Make sure the cache-contents is actual
3436

3537
async def clear_cache(self) -> None:
3638
"""Clear current cache."""
@@ -54,7 +56,9 @@ async def restore_cache(self) -> None:
5456
node_type = None
5557

5658
if node_type is None:
57-
_LOGGER.warning("Invalid NodeType in cache for mac %s: %s", mac, node_value)
59+
_LOGGER.warning(
60+
"Invalid NodeType in cache for mac %s: %s", mac, node_value
61+
)
5862
continue
5963
self._nodetypes[mac] = node_type
6064
_LOGGER.debug(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plugwise_usb"
7-
version = "0.46.1a1"
7+
version = "0.46.1a2"
88
license = "MIT"
99
keywords = ["home", "automation", "plugwise", "module", "usb"]
1010
classifiers = [

0 commit comments

Comments
 (0)