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
« 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.
4Then adapted to have more user-friendly interface.
5"""
7from __future__ import annotations
9import ctypes
10import operator
11import os
12import subprocess
13import uuid
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
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
40from mactime.errors import FSOperationError
41from mactime.errors import ArgumentsError
42from mactime.logger import logger
45if TYPE_CHECKING:
46 from collections.abc import Iterable
47 from typing import Any, Never, Self, Unpack
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 ]
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
74class TimeSpecAttrs(TypedDict):
75 created: datetime
76 modified: datetime
77 changed: datetime
78 added: datetime
79 accessed: datetime
80 backed_up: datetime
83class TimeAttrs(TimeSpecAttrs):
84 opened: datetime
87class TimeSpecArgs(TypedDict, total=False):
88 created: datetime
89 modified: datetime
90 changed: datetime
91 added: datetime
92 accessed: datetime
93 backed_up: datetime
96class BaseStructure(Structure):
97 @classmethod
98 def from_python(cls, value: Any) -> Never:
99 raise NotImplementedError
101 def to_python(self) -> Any:
102 raise NotImplementedError
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})"
111class Timespec(BaseStructure):
112 _fields_ = [("tv_sec", c_long), ("tv_nsec", c_long)]
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)
121 def to_python(self) -> datetime:
122 return datetime.fromtimestamp(
123 self.tv_sec + float(self.tv_nsec / NANOSECONDS_IN_SECOND)
124 )
127class TimespecRequest(BaseStructure):
128 _fields_ = [("time", Timespec)]
131class BulkTimespecRequest(BaseStructure):
132 _fields_ = [("times", TimespecRequest * len(ATTR_TO_NAME_MAP))]
135def format_options(iterable: Iterable[str]) -> str:
136 return "{" + ",".join(iterable) + "}"
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
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
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 )
160 FSOperationError.check_call(ret, path, "calling setattrlist")
162 logger.debug(
163 "Successfully set attributes (bitmap: 0x%08x) for %s",
164 attr_list.commonattr,
165 path,
166 )
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
180 if attr_map:
181 set_timespec_attrs(file, attr_map, no_follow=no_follow)
183 (
184 logger.debug(
185 "Successfully modified attributes for %s: stat_times: %s, attrs: %s",
186 file,
187 attr_map,
188 ),
189 )
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)
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 )
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 )
228 FSOperationError.check_call(ret, path, "calling getattrlist")
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
246 return result
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
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
267 for item in path.rglob("*"):
268 if item.is_file() or (item.is_dir() and not files_only):
269 yield item
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})
288 modify_macos_times(file, no_follow=no_follow, **to_set)