33from __future__ import annotations
44
55from asyncio import get_running_loop
6+ from contextlib import suppress
67import 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
89from 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
1013from aiofiles import open as aiofiles_open , ospath # type: ignore[import-untyped]
1114from 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 :
0 commit comments