Coverage for src/mactime/core.py: 72%

144 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-24 19:31 +0100

1"""Original C solution was found here 

2# https://apple.stackexchange.com/questions/40941/how-to-set-date-added-metadata-in-mac-os-x-10-7-lion. 

3 

4Then adapted to have more user-friendly interface. 

5""" 

6 

7from __future__ import annotations 

8 

9import ctypes 

10import operator 

11import os 

12import subprocess 

13import uuid 

14 

15from ctypes import ( 

16 POINTER, 

17 Structure, 

18 byref, 

19 c_long, 

20 c_uint16, 

21 c_uint32, 

22 create_string_buffer, 

23 sizeof, 

24) 

25from datetime import datetime 

26from decimal import Decimal 

27from functools import reduce 

28from pathlib import Path 

29from typing import TYPE_CHECKING, TypedDict 

30 

31from mactime.constants import ATTR_BIT_MAP_COUNT 

32from mactime.constants import ATTR_CMN_RETURNED_ATTRS 

33from mactime.constants import ATTR_TO_NAME_MAP 

34from mactime.constants import EPOCH 

35from mactime.constants import FSOPT_NOFOLLOW 

36from mactime.constants import NAME_TO_ATTR_MAP 

37from mactime.constants import NANOSECONDS_IN_SECOND 

38from mactime.constants import PathType 

39 

40from mactime.errors import FSOperationError 

41from mactime.errors import ArgumentsError 

42from mactime.logger import logger 

43 

44 

45if TYPE_CHECKING: 

46 from collections.abc import Iterable 

47 from typing import Any, Never, Self, Unpack 

48 

49 

50class AttrList(Structure): 

51 _fields_ = [ 

52 ("bitmapcount", c_uint16), 

53 ("reserved", c_uint16), 

54 ("commonattr", c_uint32), 

55 ("volattr", c_uint32), 

56 ("dirattr", c_uint32), 

57 ("fileattr", c_uint32), 

58 ("forkattr", c_uint32), 

59 ] 

60 

61 

62libc = ctypes.CDLL("libc.dylib", use_errno=True) 

63for func in libc.setattrlist, libc.getattrlist: 

64 func.argtypes = [ 

65 ctypes.c_char_p, 

66 POINTER(AttrList), 

67 ctypes.c_void_p, 

68 ctypes.c_size_t, 

69 c_uint32, 

70 ] 

71 func.restype = ctypes.c_int 

72 

73 

74class TimeSpecAttrs(TypedDict): 

75 created: datetime 

76 modified: datetime 

77 changed: datetime 

78 added: datetime 

79 accessed: datetime 

80 backed_up: datetime 

81 

82 

83class TimeAttrs(TimeSpecAttrs): 

84 opened: datetime 

85 

86 

87class TimeSpecArgs(TypedDict, total=False): 

88 created: datetime 

89 modified: datetime 

90 changed: datetime 

91 added: datetime 

92 accessed: datetime 

93 backed_up: datetime 

94 

95 

96class BaseStructure(Structure): 

97 @classmethod 

98 def from_python(cls, value: Any) -> Never: 

99 raise NotImplementedError 

100 

101 def to_python(self) -> Any: 

102 raise NotImplementedError 

103 

104 def __repr__(self) -> str: 

105 fields = ", ".join( 

106 f"{k}={getattr(self, k)!r}" for k, _ in self.__class__._fields_ 

107 ) 

108 return f"{self.__class__.__name__}({fields})" 

109 

110 

111class Timespec(BaseStructure): 

112 _fields_ = [("tv_sec", c_long), ("tv_nsec", c_long)] 

113 

114 @classmethod 

115 def from_python(cls, value: datetime) -> Self: 

116 timestamp = Decimal(value.timestamp()) 

117 sec = int(timestamp) 

118 nsec = int((timestamp - sec) * NANOSECONDS_IN_SECOND) 

119 return Timespec(tv_sec=sec, tv_nsec=nsec) 

120 

121 def to_python(self) -> datetime: 

122 return datetime.fromtimestamp( 

123 self.tv_sec + float(self.tv_nsec / NANOSECONDS_IN_SECOND) 

124 ) 

125 

126 

127class TimespecRequest(BaseStructure): 

128 _fields_ = [("time", Timespec)] 

129 

130 

131class BulkTimespecRequest(BaseStructure): 

132 _fields_ = [("times", TimespecRequest * len(ATTR_TO_NAME_MAP))] 

133 

134 

135def format_options(iterable: Iterable[str]) -> str: 

136 return "{" + ",".join(iterable) + "}" 

137 

138 

139def set_timespec_attrs( 

140 path: PathType, attr_map: dict[int, Timespec], no_follow: bool = False 

141) -> None: 

142 path = str(path) 

143 attr_list = AttrList() 

144 attr_list.bitmapcount = ATTR_BIT_MAP_COUNT 

145 attr_list.commonattr = 0 

146 

147 req = BulkTimespecRequest() 

148 for i, (attr, timespec) in enumerate(sorted(attr_map.items(), key=lambda a: a[0])): 

149 attr_list.commonattr |= attr 

150 req.times[i].time = timespec 

151 

152 ret = libc.setattrlist( 

153 path.encode("utf-8"), 

154 byref(attr_list), 

155 byref(req), 

156 sizeof(req), 

157 FSOPT_NOFOLLOW if no_follow else 0, 

158 ) 

159 

160 FSOperationError.check_call(ret, path, "calling setattrlist") 

161 

162 logger.debug( 

163 "Successfully set attributes (bitmap: 0x%08x) for %s", 

164 attr_list.commonattr, 

165 path, 

166 ) 

167 

168 

169def modify_macos_times( 

170 file: PathType, no_follow: bool = False, **kwargs: Unpack[TimeSpecArgs] 

171) -> None: 

172 """Modify macOS file date attributes for the given file.""" 

173 attr_map = {} 

174 for name, value in kwargs.items(): 

175 timespec = Timespec.from_python(value) 

176 attr = NAME_TO_ATTR_MAP[name] 

177 logger.info("Will attempt to set '%s' to '%s' on '%s'", name, value, file) 

178 attr_map[attr] = timespec 

179 

180 if attr_map: 

181 set_timespec_attrs(file, attr_map, no_follow=no_follow) 

182 

183 ( 

184 logger.debug( 

185 "Successfully modified attributes for %s: stat_times: %s, attrs: %s", 

186 file, 

187 attr_map, 

188 ), 

189 ) 

190 

191 

192def get_last_opened_date(path: str | os.PathLike) -> datetime: 

193 null_marker = str(uuid.uuid4()) 

194 result = subprocess.run( 

195 [ 

196 "mdls", 

197 "-raw", 

198 "-name", 

199 "kMDItemLastUsedDate", 

200 "-nullMarker", 

201 null_marker, 

202 str(path), 

203 ], 

204 check=True, 

205 capture_output=True, 

206 ) 

207 if (output := result.stdout.decode().strip()) == null_marker: 

208 return EPOCH 

209 return datetime.fromisoformat(output).replace(tzinfo=None) 

210 

211 

212def get_timespec_attrs(path: PathType, no_follow: bool = False) -> TimeSpecAttrs: 

213 file_bytes = str(path).encode("utf-8") 

214 attr_list = AttrList() 

215 attr_list.bitmapcount = ATTR_BIT_MAP_COUNT 

216 attr_list.commonattr = ( 

217 reduce(operator.or_, ATTR_TO_NAME_MAP) | ATTR_CMN_RETURNED_ATTRS 

218 ) 

219 

220 # Allocate a buffer sufficiently large for the expected data. 

221 header_size = 4 + (ATTR_BIT_MAP_COUNT * 4) 

222 buf_size = header_size + sizeof(Timespec) * len(ATTR_TO_NAME_MAP) 

223 buf = create_string_buffer(buf_size) 

224 ret = libc.getattrlist( 

225 file_bytes, byref(attr_list), buf, buf_size, FSOPT_NOFOLLOW if no_follow else 0 

226 ) 

227 

228 FSOperationError.check_call(ret, path, "calling getattrlist") 

229 

230 length = int.from_bytes(buf.raw[0:4], byteorder="little") 

231 result: dict[str, datetime] = {} 

232 offset = header_size 

233 value_size = sizeof(Timespec) 

234 for const, name in ATTR_TO_NAME_MAP.items(): 

235 if offset + value_size > length: 

236 raise FSOperationError( 

237 str(path), 

238 f"reading data from getattrlist", 

239 0, 

240 message=f"Not enough data returned for attribute {name}", 

241 ) 

242 value = ctypes.cast(ctypes.byref(buf, offset), POINTER(Timespec)).contents 

243 result[name] = value.to_python() 

244 offset += value_size 

245 

246 return result 

247 

248 

249def resolve_paths( 

250 paths: list[str], 

251 recursive: bool = False, 

252 include_root: bool = False, 

253 files_only: bool = False, 

254): 

255 if not recursive: 

256 yield from paths 

257 return 

258 

259 for path in paths: 

260 path = Path(path) 

261 if path.is_file(): 

262 yield path 

263 elif path.is_dir(): 

264 if include_root: 

265 yield path 

266 

267 for item in path.rglob("*"): 

268 if item.is_file() or (item.is_dir() and not files_only): 

269 yield item 

270 

271 

272def set_file_times( 

273 file: PathType, 

274 to_set: dict[str, datetime], 

275 from_another_attributes: dict[str, str] | None = None, 

276 from_opened: list[str] | None = None, 

277 no_follow: bool = False, 

278) -> None: 

279 if from_another_attributes: 

280 attrs = get_timespec_attrs(file, no_follow=no_follow) 

281 to_set.update( 

282 {name: attrs[source] for name, source in from_another_attributes.items()}, 

283 ) 

284 if from_opened: 

285 last_opened = get_last_opened_date(file) 

286 to_set.update({name: last_opened for name in from_opened}) 

287 

288 modify_macos_times(file, no_follow=no_follow, **to_set)