11import json
2+ import os
23import os .path
34import re
5+ import tempfile
46from typing import List , Optional , Union
57
68
9+ class AtomicPath :
10+ """Context manager for atomic file writes.
11+
12+ Ensures that files are either written completely or not at all,
13+ preventing partial/corrupted files from power failures or crashes.
14+
15+ Usage:
16+ with AtomicPath(target_path, allow_override=False) as temp_path:
17+ # Write to temp_path
18+ with open(temp_path, 'w') as f:
19+ f.write(data)
20+ # File is atomically moved to target_path on successful exit
21+ """
22+
23+ def __init__ (self , target_path : str , allow_override : bool = False ):
24+ self .target_path = target_path
25+ self .allow_override = allow_override
26+ self .temp_path : Optional [str ] = None
27+ self .temp_file = None
28+
29+ def __enter__ (self ) -> str :
30+ ensure_write_is_allowed (
31+ path = self .target_path , allow_override = self .allow_override
32+ )
33+ ensure_parent_dir_exists (path = self .target_path )
34+
35+ dir_name = os .path .dirname (os .path .abspath (self .target_path ))
36+ base_name = os .path .basename (self .target_path )
37+ self .temp_file = tempfile .NamedTemporaryFile (
38+ dir = dir_name , prefix = ".tmp_" , suffix = "_" + base_name , delete = False
39+ )
40+ self .temp_path = self .temp_file .name
41+ self .temp_file .close ()
42+ return self .temp_path
43+
44+ def __exit__ (self , exc_type , exc_val , exc_tb ):
45+ if exc_type is None :
46+ try :
47+ if os .name == "nt" : # Windows
48+ if os .path .exists (self .target_path ):
49+ os .remove (self .target_path )
50+ os .rename (self .temp_path , self .target_path )
51+ else : # POSIX
52+ os .replace (self .temp_path , self .target_path )
53+ except Exception :
54+ try :
55+ os .unlink (self .temp_path )
56+ except OSError :
57+ pass
58+ raise
59+ else :
60+ # Error occurred - clean up temp file
61+ try :
62+ os .unlink (self .temp_path )
63+ except OSError :
64+ pass
65+ return False # Don't suppress exceptions
66+
67+
768def read_text_file (
869 path : str ,
970 split_lines : bool = False ,
@@ -28,31 +89,72 @@ def read_json(path: str, **kwargs) -> Optional[Union[dict, list]]:
2889
2990
3091def dump_json (
31- path : str , content : Union [dict , list ], allow_override : bool = False , ** kwargs
92+ path : str ,
93+ content : Union [dict , list ],
94+ allow_override : bool = False ,
95+ fsync : bool = False ,
96+ ** kwargs ,
3297) -> None :
3398 ensure_write_is_allowed (path = path , allow_override = allow_override )
3499 ensure_parent_dir_exists (path = path )
35100 with open (path , "w" ) as f :
36101 json .dump (content , fp = f , ** kwargs )
102+ if fsync :
103+ os .fsync (f .fileno ())
104+
105+
106+ def dump_json_atomic (
107+ path : str , content : Union [dict , list ], allow_override : bool = False , ** kwargs
108+ ) -> None :
109+ with AtomicPath (path , allow_override = allow_override ) as temp_path :
110+ dump_json (temp_path , content , allow_override = True , fsync = True , ** kwargs )
37111
38112
39113def dump_text_lines (
40114 path : str ,
41115 content : List [str ],
42116 allow_override : bool = False ,
43117 lines_connector : str = "\n " ,
118+ fsync : bool = False ,
44119) -> None :
45120 ensure_write_is_allowed (path = path , allow_override = allow_override )
46121 ensure_parent_dir_exists (path = path )
47122 with open (path , "w" ) as f :
48123 f .write (lines_connector .join (content ))
124+ if fsync :
125+ os .fsync (f .fileno ())
49126
50127
51- def dump_bytes (path : str , content : bytes , allow_override : bool = False ) -> None :
128+ def dump_text_lines_atomic (
129+ path : str ,
130+ content : List [str ],
131+ allow_override : bool = False ,
132+ lines_connector : str = "\n " ,
133+ ) -> None :
134+ with AtomicPath (path , allow_override = allow_override ) as temp_path :
135+ dump_text_lines (
136+ temp_path ,
137+ content ,
138+ allow_override = True ,
139+ lines_connector = lines_connector ,
140+ fsync = True ,
141+ )
142+
143+
144+ def dump_bytes (
145+ path : str , content : bytes , allow_override : bool = False , fsync : bool = False
146+ ) -> None :
52147 ensure_write_is_allowed (path = path , allow_override = allow_override )
53148 ensure_parent_dir_exists (path = path )
54149 with open (path , "wb" ) as f :
55150 f .write (content )
151+ if fsync :
152+ os .fsync (f .fileno ())
153+
154+
155+ def dump_bytes_atomic (path : str , content : bytes , allow_override : bool = False ) -> None :
156+ with AtomicPath (path , allow_override = allow_override ) as temp_path :
157+ dump_bytes (temp_path , content , allow_override = True , fsync = True )
56158
57159
58160def ensure_parent_dir_exists (path : str ) -> None :
0 commit comments