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
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 19:31 +0100
1from __future__ import annotations
3from abc import ABC
4from collections.abc import Iterable
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
26from dataclasses import dataclass, field
27from datetime import datetime
29from mactime.constants import OPENED_NAME
30from mactime.constants import BACKED_UP_NAME
31from mactime.constants import CHANGED_NAME
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)
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 )
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 )
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")
83@dataclass
84class GetCommand(_GlobalArgs):
85 """
86 Get file attributes
88 Examples:
89 # Show all attributes
90 mactime get file.txt
92 # Show only modification time
93 mactime get file.txt modified
95 # Show specific attribute using shorthand
96 mactime get file.txt m
98 # Include stat times in output
99 mactime get -s file.txt
100 """
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 )
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)
117 if not self.is_cli:
118 return attrs
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}")
128 return attrs # for typechecking sake
131@dataclass
132class SetCommand(_RecursiveArgs):
133 """
134 Set file attributes
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
144 Examples:
145 # Set modification time
146 mactime set file.txt -m "2024-02-21T10:00:00"
148 # Set created time to match modified time
149 mactime set file.txt -c m
151 # Set added time to now
152 mactime set file.txt -d now
154 # Set multiple attributes at once
155 mactime set file.txt -m "2024-02-21T10:00" -c "2024-02-20T15:30"
157 # Process all files in directory recursively
158 mactime set ./dir -r -m "2024-02-21T10:00:00"
159 """
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)
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 )
208 def __post_init__(self):
209 super().__post_init__()
211 if not any((self.to_set, self.from_another_attributes, self.from_opened)):
212 self._prepare_args()
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")
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
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()
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 )
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 )
280@dataclass
281class TransferCommand(_RecursiveArgs):
282 """
283 Transfer attributes from source file to target files
285 Examples:
286 # Transfer specific attributes
287 mactime transfer source.txt target.txt -mc
289 # Transfer all attributes
290 mactime transfer source.txt target.txt --all
292 # Transfer to multiple files
293 mactime transfer source.txt target1.txt target2.txt -m
295 # Transfer recursively to all files in directory
296 mactime transfer source.txt ./dir -r --all
297 """
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 )
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 )
366 if OPENED_NAME in self.attrs:
367 raise ArgumentsError(DATE_LAST_OPENED_IS_READ_ONlY)
369 def __call__(self):
370 source_attrs = get_timespec_attrs(self.source)
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
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 )
393@dataclass
394class ResetCommand(_RecursiveArgs):
395 """
396 Reset file attributes to epoch (Jan 1, 1970).
398 Examples:
399 # Reset modification and creation time
400 mactime reset file.txt -m -c
402 # Reset all attributes
403 mactime reset file.txt --all
405 # Reset attributes for all files in directory
406 mactime reset ./dir -r --all
407 """
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 )
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)
469 if OPENED_NAME in self.to_set:
470 raise ArgumentsError(DATE_LAST_OPENED_IS_READ_ONlY)
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 )
486class MacTime(CLI):
487 get = GetCommand
488 set = SetCommand
489 transfer = TransferCommand
490 reset = ResetCommand
493def main():
494 MacTime.run()