Coverage for src/mactime/cli.py: 68%

173 statements  

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

1from __future__ import annotations 

2 

3from abc import ABC 

4from collections.abc import Iterable 

5 

6from mactime._cli_interface import CLI 

7from mactime._cli_interface import Command 

8from mactime.constants import ATTR_NAME_ARG_CHOICES 

9from mactime.constants import EPOCH 

10from mactime.constants import NAME_TO_SHORTHAND 

11from mactime.constants import SHORTHAND_TO_NAME 

12from mactime.constants import NAME_TO_ATTR_MAP 

13from mactime.constants import TIME_ALIASES 

14from mactime.core import TimeAttrs 

15from mactime.constants import WRITABLE_NAMES 

16from mactime.errors import ArgumentsError 

17from mactime._cli_interface import arg 

18from mactime.core import format_options 

19from mactime.core import resolve_paths 

20from mactime.core import get_last_opened_date 

21from mactime.core import get_timespec_attrs 

22from mactime.logger import logger 

23from mactime.core import set_file_times 

24 

25 

26from dataclasses import dataclass, field 

27from datetime import datetime 

28 

29from mactime.constants import OPENED_NAME 

30from mactime.constants import BACKED_UP_NAME 

31from mactime.constants import CHANGED_NAME 

32 

33 

34DATE_LAST_OPENED_IS_READ_ONlY = ( 

35 "Date Last Opened is not a file attribute. " 

36 "It's stored in Spotlight index. There's no discovered a way to modify it." 

37) 

38 

39 

40@dataclass 

41class _GlobalArgs(Command, ABC): 

42 no_follow: bool = arg( 

43 "-n", 

44 default=False, 

45 help="Don't follow symlinks at the last component of the path. Output/modify attributes of symlink itself.", 

46 ) 

47 

48 

49@dataclass 

50class _RecursiveArgs(_GlobalArgs, ABC): 

51 recursive: bool = arg( 

52 "-r", 

53 default=False, 

54 help="Recursively process files in subdirectories.", 

55 ) 

56 include_root: bool = arg( 

57 "-i", 

58 default=False, 

59 help="Always process the root directories provided as arguments (only with -r). Root directories are excluded by default.", 

60 ) 

61 files_only: bool = arg( 

62 "-f", 

63 default=False, 

64 help="Only process files, skip all non-root directories when recursing (only with -r). Nested directories are included by default.", 

65 ) 

66 

67 def __post_init__(self): 

68 super().__post_init__() 

69 if not self.recursive: 

70 if self.include_root: 

71 raise ArgumentsError( 

72 "--include-root can only be used with -r/--recursive" 

73 ) 

74 if self.files_only: 

75 ArgumentsError("--files-only can only be used with -r/--recursive") 

76 else: 

77 if ( 

78 super().no_follow 

79 ): # PyCharm only understands this construction for some reason 

80 ArgumentsError("--no-follow cannot be used with -r/--recursive") 

81 

82 

83@dataclass 

84class GetCommand(_GlobalArgs): 

85 """ 

86 Get file attributes 

87 

88 Examples: 

89 # Show all attributes 

90 mactime get file.txt 

91 

92 # Show only modification time 

93 mactime get file.txt modified 

94 

95 # Show specific attribute using shorthand 

96 mactime get file.txt m 

97 

98 # Include stat times in output 

99 mactime get -s file.txt 

100 """ 

101 

102 file: str = arg( 

103 nargs=None, 

104 help="File or directory to inspect", 

105 ) 

106 attr: str | None = arg( 

107 nargs="?", 

108 choices=ATTR_NAME_ARG_CHOICES, 

109 default=None, 

110 help="Name of the attribute to get. Only print it to stdout.", 

111 ) 

112 

113 def __call__(self) -> TimeAttrs: 

114 attrs = get_timespec_attrs(self.file, no_follow=self.no_follow) 

115 attrs[OPENED_NAME] = get_last_opened_date(self.file) 

116 

117 if not self.is_cli: 

118 return attrs 

119 

120 if self.attr: 

121 attr = SHORTHAND_TO_NAME.get(self.attr, self.attr) 

122 print(attrs[attr]) 

123 else: 

124 max_key_length = max(len(key) for key in attrs) 

125 for key, value in attrs.items(): 

126 print(f"{key:<{max_key_length}}: {value}") 

127 

128 return attrs # for typechecking sake 

129 

130 

131@dataclass 

132class SetCommand(_RecursiveArgs): 

133 """ 

134 Set file attributes 

135 

136 Values for attributes can be either: 

137 - date in ISO 8601 format, e.g. 2024-02-21T10:00:00 

138 - another attribute as source (full name or a shorthand) 

139 - one of special values: 

140 - now 

141 - yesterday 

142 - epoch 

143 

144 Examples: 

145 # Set modification time 

146 mactime set file.txt -m "2024-02-21T10:00:00" 

147 

148 # Set created time to match modified time 

149 mactime set file.txt -c m 

150 

151 # Set added time to now 

152 mactime set file.txt -d now 

153 

154 # Set multiple attributes at once 

155 mactime set file.txt -m "2024-02-21T10:00" -c "2024-02-20T15:30" 

156 

157 # Process all files in directory recursively 

158 mactime set ./dir -r -m "2024-02-21T10:00:00" 

159 """ 

160 

161 file: list[str] = arg( 

162 nargs="+", 

163 help="File or directory to modify", 

164 ) 

165 to_set: dict[str, datetime] = field(default_factory=dict) 

166 from_another_attributes: dict[str, str] = field(default_factory=dict) 

167 from_opened: list[str] = field(default_factory=list) 

168 

169 modified: datetime | str | None = arg( 

170 "-m", 

171 default=None, 

172 help="Allowed values described above.", 

173 ) 

174 created: datetime | str | None = arg( 

175 "-c", 

176 default=None, 

177 help="Allowed values described above.", 

178 ) 

179 accessed: datetime | str | None = arg( 

180 "-a", 

181 default=None, 

182 help="Allowed values described above.", 

183 ) 

184 changed: datetime | str | None = arg( 

185 "-C", 

186 default=None, 

187 help="Allowed values described above.\n" 

188 "This attribute is updated to current system time whenever some attributes are changed.\n" 

189 "It's impossible to set an arbitrary value to this attribute.", 

190 ) 

191 backed_up: datetime | str | None = arg( 

192 "-b", 

193 default=None, 

194 help="Allowed values described above. " 

195 "For some reason this attribute is not writable. `setettrlist` just ignores it.", 

196 ) 

197 added: datetime | str | None = arg( 

198 "-d", 

199 default=None, 

200 help="Either date in ISO 8601 format or another attribute as source.", 

201 ) 

202 opened: datetime | str | None = arg( 

203 "-o", 

204 default=None, 

205 help="Using this argument currently will result in error since there's no way to even attempt to change it.", 

206 ) 

207 

208 def __post_init__(self): 

209 super().__post_init__() 

210 

211 if not any((self.to_set, self.from_another_attributes, self.from_opened)): 

212 self._prepare_args() 

213 

214 if OPENED_NAME in self.to_set: 

215 raise ArgumentsError(DATE_LAST_OPENED_IS_READ_ONlY) 

216 if CHANGED_NAME in self.to_set or CHANGED_NAME in self.from_another_attributes: 

217 logger.warning( 

218 f"'{CHANGED_NAME}' argument will be set to current time, not '%s'", 

219 self.to_set.get( 

220 CHANGED_NAME, self.from_another_attributes.get(CHANGED_NAME) 

221 ), 

222 ) 

223 if ( 

224 BACKED_UP_NAME in self.to_set 

225 or BACKED_UP_NAME in self.from_another_attributes 

226 ): 

227 logger.warning(f"'{BACKED_UP_NAME}' argument will be ignored") 

228 

229 def _prepare_args(self): 

230 from_time_aliases = {} 

231 for name in NAME_TO_ATTR_MAP: 

232 if (value := getattr(self, name)) is None: 

233 continue 

234 

235 if isinstance(value, datetime): 

236 self.to_set[name] = value 

237 continue 

238 elif (func := TIME_ALIASES.get(value)) is not None: 

239 # in case we have same time aliases for multiple attrs 

240 # we want to compute them only once 

241 if value in from_time_aliases: 

242 self.to_set[name] = from_time_aliases[value] 

243 else: 

244 self.to_set[name] = from_time_aliases[value] = func() 

245 

246 elif value in NAME_TO_ATTR_MAP or value in SHORTHAND_TO_NAME: 

247 value = SHORTHAND_TO_NAME.get(value, value) 

248 if value == OPENED_NAME: 

249 self.from_opened.append(name) 

250 else: 

251 self.from_another_attributes[name] = SHORTHAND_TO_NAME.get( 

252 value, value 

253 ) 

254 else: 

255 try: 

256 self.to_set[name] = datetime.fromisoformat(value) 

257 except ValueError: 

258 raise ArgumentsError( 

259 f"'--{name}' must be a valid ISO 8601 date or one of {format_options(TIME_ALIASES)} or " 

260 f"{format_options(ATTR_NAME_ARG_CHOICES)}" 

261 f" not {value!r}" 

262 ) 

263 

264 def __call__(self): 

265 for file in resolve_paths( 

266 self.file, 

267 self.recursive, 

268 self.include_root, 

269 self.files_only, 

270 ): 

271 set_file_times( 

272 file, 

273 self.to_set, 

274 self.from_another_attributes, 

275 self.from_opened, 

276 no_follow=self.no_follow, 

277 ) 

278 

279 

280@dataclass 

281class TransferCommand(_RecursiveArgs): 

282 """ 

283 Transfer attributes from source file to target files 

284 

285 Examples: 

286 # Transfer specific attributes 

287 mactime transfer source.txt target.txt -mc 

288 

289 # Transfer all attributes 

290 mactime transfer source.txt target.txt --all 

291 

292 # Transfer to multiple files 

293 mactime transfer source.txt target1.txt target2.txt -m 

294 

295 # Transfer recursively to all files in directory 

296 mactime transfer source.txt ./dir -r --all 

297 """ 

298 

299 source: str = arg( 

300 help="Source file to copy attributes from", 

301 ) 

302 target: list[str] = arg( 

303 nargs="+", 

304 help="Target file(s) to copy attributes to", 

305 ) 

306 attrs: Iterable[str] = field(default=()) 

307 all: bool = arg( 

308 "-A", 

309 default=False, 

310 help="Transfer all supported attributes", 

311 ) 

312 modified: bool = arg( 

313 "-m", 

314 default=False, 

315 help="Transfer the modified attribute", 

316 ) 

317 created: bool = arg( 

318 "-c", 

319 default=False, 

320 help="Transfer the created attribute", 

321 ) 

322 accessed: bool = arg( 

323 "-a", 

324 default=False, 

325 help="Transfer the accessed attribute", 

326 ) 

327 changed: bool = arg( 

328 "-C", 

329 default=False, 

330 help="Transfer the changed attribute", 

331 ) 

332 backed_up: bool = arg( 

333 "-b", 

334 suppress=True, 

335 default=False, 

336 help="Transfer the backed_up attribute", 

337 ) 

338 opened: bool = arg( 

339 "-o", 

340 suppress=True, 

341 default=False, 

342 help="Transfer the opened attribute", 

343 ) 

344 added: bool = arg( 

345 "-d", 

346 suppress=True, 

347 default=False, 

348 help="Transfer the added attribute", 

349 ) 

350 

351 def __post_init__(self) -> None: 

352 super().__post_init__() 

353 if ( 

354 self.attrs 

355 ): # set manually via MacTimes.transfer(file, target, attrs=("c", "d") 

356 return 

357 if self.all: 

358 self.attrs = WRITABLE_NAMES 

359 else: 

360 self.attrs = {n for n in NAME_TO_ATTR_MAP if getattr(self, n)} 

361 if not self.attrs: 

362 raise ArgumentsError( 

363 "No attributes selected for transfer. Use --all or specify attributes" 

364 ) 

365 

366 if OPENED_NAME in self.attrs: 

367 raise ArgumentsError(DATE_LAST_OPENED_IS_READ_ONlY) 

368 

369 def __call__(self): 

370 source_attrs = get_timespec_attrs(self.source) 

371 

372 to_set = {} 

373 for name in self.attrs: 

374 value = source_attrs[name] 

375 logger.info( 

376 f"Will attempt to transfer '{name}={value}' from '{self.source}'" 

377 ) 

378 to_set[name] = value 

379 

380 for file in resolve_paths( 

381 self.target, 

382 self.recursive, 

383 self.include_root, 

384 self.files_only, 

385 ): 

386 set_file_times( 

387 file, 

388 to_set, 

389 no_follow=self.no_follow, 

390 ) 

391 

392 

393@dataclass 

394class ResetCommand(_RecursiveArgs): 

395 """ 

396 Reset file attributes to epoch (Jan 1, 1970). 

397 

398 Examples: 

399 # Reset modification and creation time 

400 mactime reset file.txt -m -c 

401 

402 # Reset all attributes 

403 mactime reset file.txt --all 

404 

405 # Reset attributes for all files in directory 

406 mactime reset ./dir -r --all 

407 """ 

408 

409 files: list[str] = arg( 

410 nargs="+", 

411 help="File or directory to modify", 

412 ) 

413 to_set: dict[str, datetime] = field(default_factory=dict) 

414 all: bool = arg( 

415 "-A", 

416 default=False, 

417 help="Reset all supported attributes", 

418 ) 

419 modified: bool = arg( 

420 "-m", 

421 default=False, 

422 help="Reset the 'modified' attribute.", 

423 ) 

424 created: bool = arg( 

425 "-c", 

426 default=False, 

427 help="Reset the 'created' attribute.", 

428 ) 

429 accessed: bool = arg( 

430 "-a", 

431 default=False, 

432 help="Reset the 'accessed' attribute.", 

433 ) 

434 changed: bool = arg( 

435 "-g", 

436 default=False, 

437 help="This attribute is updated to current system time whenever some attributes are changed.\n" 

438 "It's impossible to set an arbitrary value to this attribute.", 

439 ) 

440 backed_up: bool = arg( 

441 "-b", 

442 default=False, 

443 help="Attempt to reset 'backed_up' attribute. For some reason `setettrlist` just ignores it.", 

444 ) 

445 opened: bool = arg( 

446 "-o", 

447 default=False, 

448 help="Attempt to reset 'opened' attribute. Currently will result in error since there's no way to even attempt to change it.", 

449 ) 

450 added: bool = arg( 

451 "-d", 

452 default=False, 

453 help="Reset the 'added' attribute.", 

454 ) 

455 

456 def __post_init__(self): 

457 super().__post_init__() 

458 if not self.to_set: 

459 if self.all: 

460 selected_attrs = WRITABLE_NAMES 

461 else: 

462 selected_attrs = [n for n in NAME_TO_ATTR_MAP if getattr(self, n)] 

463 if not selected_attrs: 

464 raise ArgumentsError( 

465 "No attributes selected for reset. Use --all or specify attributes" 

466 ) 

467 self.to_set = dict.fromkeys(selected_attrs, EPOCH) 

468 

469 if OPENED_NAME in self.to_set: 

470 raise ArgumentsError(DATE_LAST_OPENED_IS_READ_ONlY) 

471 

472 def __call__(self) -> None: 

473 for file in resolve_paths( 

474 self.files, 

475 self.recursive, 

476 self.include_root, 

477 self.files_only, 

478 ): 

479 set_file_times( 

480 file, 

481 self.to_set, 

482 no_follow=self.no_follow, 

483 ) 

484 

485 

486class MacTime(CLI): 

487 get = GetCommand 

488 set = SetCommand 

489 transfer = TransferCommand 

490 reset = ResetCommand 

491 

492 

493def main(): 

494 MacTime.run()