Source code for mail_deduplicate.action

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Actions performed once the selection is settled: copy, move, delete or hardlink.

Each action ID pairs an operation verb with the subset of mails it applies to, and
`Action.perform()` routes one to the other.
"""

from __future__ import annotations

import filecmp
import logging
import os
from collections import Counter
from contextlib import contextmanager
from typing import cast
from uuid import uuid4

from click_extra import OperationTrail, format_size, get_current_theme

from . import StrEnum
from .deduplicate import Stat
from .mail_box import FOLDER_FORMAT_CLASSES, create_box

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Collection, Iterator
    from mailbox import Mailbox

    from .deduplicate import Deduplicate
    from .mail import DedupMailMixin


[docs] @contextmanager def export_box(dedup: Deduplicate) -> Iterator[Mailbox | None]: """Context manager for export box operations.""" if dedup.conf["dry_run"]: yield None else: assert dedup.conf["export"] box = create_box( dedup.conf["export"], dedup.conf["export_format"], dedup.conf["export_append"], ) try: yield box finally: logging.debug(f"Close {dedup.conf['export']}") box.close()
[docs] def dry_run_prefix(dedup: Deduplicate) -> str: """Marks a summary as describing what a run would have done. A dry run reports through the same trail as a real one, so what it did not do is said once at the end rather than warned about for every single mail. """ return "DRY RUN: would have " if dedup.conf["dry_run"] else ""
[docs] def copy_mails(dedup: Deduplicate, mails: Collection[DedupMailMixin]) -> None: """Copy provided `mails` to a brand new box or an existing one.""" trail = OperationTrail(label="Copying", unit="mails", total=len(mails)) with export_box(dedup) as box: for mail in mails: logging.debug(f"Copying {mail!r} to {dedup.conf['export']}...") dedup.stats[Stat.MAIL_COPIED] += 1 if not dedup.conf["dry_run"]: # The box is only left closed on a dry run, ruled out just above. assert box is not None with mail.hydrated(): box.add(mail) trail.mark(True, f"{mail!r} copied") trail.finish( trail.ok_count == len(mails), f"{dry_run_prefix(dedup)}Copied {trail.ok_count}/{len(mails)} mails", )
[docs] def move_mails(dedup: Deduplicate, mails: Collection[DedupMailMixin]) -> None: """Move provided `mails` to a brand new box or an existing one.""" trail = OperationTrail(label="Moving", unit="mails", total=len(mails)) with export_box(dedup) as box: for mail in mails: logging.debug( f"Move {mail!r} from {mail.source_path} to {dedup.conf['export']}..." ) dedup.stats[Stat.MAIL_MOVED] += 1 if not dedup.conf["dry_run"]: # The box is only left closed on a dry run, ruled out just above. assert box is not None with mail.hydrated(): box.add(mail) # Identity attributes are set the moment a mail is read from its # box, which every mail reaching an action has been. dedup.sources[cast("str", mail.source_path)].remove( cast("str", mail.mail_id) ) trail.mark(True, f"{mail!r} moved") trail.finish( trail.ok_count == len(mails), f"{dry_run_prefix(dedup)}Moved {trail.ok_count}/{len(mails)} mails", )
[docs] def delete_mails(dedup: Deduplicate, mails: Collection[DedupMailMixin]) -> None: """Remove provided `mails` in-place, from their original boxes.""" trail = OperationTrail(label="Deleting", unit="mails", total=len(mails)) for mail in mails: logging.debug(f"Deleting {mail!r} in-place...") dedup.stats[Stat.MAIL_DELETED] += 1 if not dedup.conf["dry_run"]: # Identity attributes are set the moment a mail is read from its box, # which every mail reaching an action has been. dedup.sources[cast("str", mail.source_path)].remove( cast("str", mail.mail_id) ) trail.mark(True, f"{mail!r} deleted") trail.finish( trail.ok_count == len(mails), f"{dry_run_prefix(dedup)}Deleted {trail.ok_count}/{len(mails)} mails", )
LINK_TEMP_PREFIX = ".mdedup-hardlink-" """Prefix of the temporary link a mail is replaced through. Every folder-based format skips dot-prefixed files when listing its mails, so a temporary left behind by an interrupted run is never read back as one. """
[docs] def has_own_file(mail: DedupMailMixin) -> bool: """Whether the mail is backed by a file holding it alone. File-based boxes pack all their mails into the box's single file, which is what `path` returns for each of them: there would be nothing to link but the whole box. """ return isinstance(mail.box, FOLDER_FORMAT_CLASSES)
OPERATIONS: dict[str, Callable[[Deduplicate, Collection[DedupMailMixin]], None]] = { "copy": copy_mails, "move": move_mails, "delete": delete_mails, "hardlink": hardlink_mails, } """The operation functions above, keyed by the verb half of an action ID. All share the same signature: the deduplication they report to, and the mails they apply to. """
[docs] class Action(StrEnum): """Define all available action IDs. An action ID joins an operation verb to the subset of mails it applies to: the `*-selected` actions act on the mails kept by the selection, the `*-discarded` ones on the mails it discarded. """ COPY_SELECTED = "copy-selected" COPY_DISCARDED = "copy-discarded" MOVE_SELECTED = "move-selected" MOVE_DISCARDED = "move-discarded" DELETE_SELECTED = "delete-selected" DELETE_DISCARDED = "delete-discarded" HARDLINK_DISCARDED = "hardlink-discarded" @property def verb(self) -> str: """The operation half of the action ID, keying into `OPERATIONS`.""" return self.value.partition("-")[0] @property def acts_on_discarded(self) -> bool: """Whether the action applies to the discarded mails rather than the selected ones.""" return self.value.endswith("-discarded")
[docs] def targets(self, dedup: Deduplicate) -> set[DedupMailMixin]: """The subset of mails this action applies to.""" return dedup.discard if self.acts_on_discarded else dedup.selection
[docs] def perform(self, dedup: Deduplicate) -> None: """Perform the action on the subset of mails it targets.""" logging.info(f"Perform {get_current_theme().choice(str(self))} action...") selection_count = len(dedup.selection) if selection_count == 0: logging.warning("No mail selected to perform action on.") return logging.info(f"{selection_count} mails selected for action.") if dedup.conf["dry_run"]: # Said once, and loudly. The statistics below count what the action # would have touched, so without this line a dry run reads exactly like # a real one. The trail's own summary cannot carry the warning alone: # it only shows on an interactive terminal. logging.warning( f"DRY RUN: {len(self.targets(dedup))} mails would be acted upon, " "but none will be altered.", ) # Check the selection is consistent with the statistics gathered during # the selection phase. assert ( selection_count == dedup.stats[Stat.MAIL_SELECTED] + dedup.stats[Stat.MAIL_UNIQUE] ) OPERATIONS[self.verb](dedup, self.targets(dedup))