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 enum import StrEnum
from typing import cast
from uuid import uuid4

from click_extra import OperationTrail, format_size, get_current_theme

from .bug_report import blame
from .deduplicate import Stat, path_order
from .mail_box import FOLDER_FORMAT_CLASSES, add_mail, create_box

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

    from .deduplicate import Deduplicate
    from .mail import DedupMailMixin


[docs] @contextmanager def export_box(dedup: Deduplicate) -> Generator[Mailbox | None, None, 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, and this prefix does for the trail's closing summary what `outcome()` does for each of its lines. """ return "DRY RUN: would have " if dedup.conf["dry_run"] else ""
[docs] def outcome(dedup: Deduplicate, done: str) -> str: """Words what happened to a mail, for its line of the trail. A dry run touches no mail, so it says what the action would have done to each of them, where a real run reports it as done. """ return f"would be {done}" if dedup.conf["dry_run"] else done
[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 blame(mail, dedup.step): add_mail(box, mail) trail.mark(True, f"{mail!r} {outcome(dedup, '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 blame(mail, dedup.step): add_mail(box, 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} {outcome(dedup, '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. with blame(mail, dedup.step): dedup.sources[cast("str", mail.source_path)].remove( cast("str", mail.mail_id) ) trail.mark(True, f"{mail!r} {outcome(dedup, '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. The mails that have no duplicate belong to the selection, and so do the mails with too few headers to be hashed: the copy and move actions carry them into the box they build. `spares_unique` explains why the delete one leaves them behind. """ 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, not the selected ones.""" return self.value.endswith("-discarded") @property def spares_unique(self) -> bool: """Whether the action must leave the mails that have no duplicate alone. A mail alone in its duplicate set is kept, so it belongs to the selection every `*-selected` action targets. So does a mail with too few headers to be hashed, which joins no set at all. Copying and moving it is what makes the exported box hold the whole deduplicated corpus rather than its duplicates only, and neither loses it. Deleting it does: no strategy ever ruled on that mail, nothing was written anywhere else, and there is no other copy of it to fall back on. So the deletion applies to the mails a strategy really picked. See: https://github.com/kdeldycke/mail-deduplicate/issues/1053 """ return self is Action.DELETE_SELECTED
[docs] def targets(self, dedup: Deduplicate) -> set[DedupMailMixin]: """The subset of mails this action applies to.""" if self.acts_on_discarded: return dedup.discard if self.spares_unique: return dedup.selection - dedup.unique return dedup.selection
[docs] def perform(self, dedup: Deduplicate) -> None: """Perform the action on the subset of mails it targets.""" # Names the step in a bug report: copying, moving, deleting or hardlinking. dedup.step = f"{self.verb.removesuffix('e')}ing" 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.") # Sorted, so the trail below lists the same mails in the same order on every # run, and two runs on the same boxes diff. targets = sorted(self.targets(dedup), key=path_order) if self.spares_unique and dedup.unique: # Said before the count below, which it accounts for. logging.warning( f"{len(dedup.unique)} mails left untouched: no strategy ruled on " "them, so deleting one could remove its only copy.", ) if dedup.conf["dry_run"]: # Said once, and loudly, before the first mail. The trail words its lines # and its summary as what would have happened, but the statistics below # count those mails exactly as a real run does. logging.warning( f"DRY RUN: {len(targets)} 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] + dedup.stats[Stat.MAIL_UNHASHED] ) OPERATIONS[self.verb](dedup, targets)