diff --git a/src/password_manager/__main__.py b/src/password_manager/__main__.py new file mode 100644 index 0000000..690af0d --- /dev/null +++ b/src/password_manager/__main__.py @@ -0,0 +1,8 @@ +"""@brief Entry point for `python -m password_manager`. + +@file __main__.py +""" + +from password_manager.main import main + +main() diff --git a/src/password_manager/main.py b/src/password_manager/main.py new file mode 100644 index 0000000..3775cdb --- /dev/null +++ b/src/password_manager/main.py @@ -0,0 +1,39 @@ +"""@brief Composition root: wires the layers together and runs the window. + +@file main.py +""" + +import tkinter as tk + +from password_manager import constants +from password_manager.application.use_cases import ( + FindPasswordEntry, + GeneratePassword, + SavePasswordEntry, +) +from password_manager.infrastructure.persistence.json_password_repository import ( + JsonPasswordRepository, +) +from password_manager.presentation.features.password_manager import ( + password_manager_view as view_module, +) +from password_manager.presentation.features.password_manager import ( + password_manager_view_model as view_model_module, +) + + +def main() -> None: + """@brief Build the application and run the Tk main loop.""" + repository = JsonPasswordRepository(constants.DATA_FILE_PATH) + view_model = view_model_module.PasswordManagerViewModel( + SavePasswordEntry(repository), + FindPasswordEntry(repository), + GeneratePassword(), + ) + root = tk.Tk() + view_module.PasswordManagerView(root, view_model) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/password_manager/presentation/__init__.py b/src/password_manager/presentation/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/presentation/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/presentation/features/__init__.py b/src/password_manager/presentation/features/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/presentation/features/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/presentation/features/password_manager/__init__.py b/src/password_manager/presentation/features/password_manager/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/presentation/features/password_manager/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/presentation/features/password_manager/password_manager_view.py b/src/password_manager/presentation/features/password_manager/password_manager_view.py new file mode 100644 index 0000000..e2b4904 --- /dev/null +++ b/src/password_manager/presentation/features/password_manager/password_manager_view.py @@ -0,0 +1,156 @@ +"""@brief Tkinter view of the password manager screen. + +@file password_manager_view.py +""" + +import tkinter as tk +from tkinter import messagebox + +from password_manager import constants +from password_manager.presentation.features.password_manager import ( + password_manager_view_model as view_model_module, +) + + +class PasswordManagerView: + """@brief The window: shows the fields and raises events to the view model.""" + + def __init__( + self, root: tk.Tk, view_model: view_model_module.PasswordManagerViewModel + ) -> None: + """@brief Build the widgets on a root window. + + @param root The Tk root window. + @param view_model The view model the events are forwarded to. + """ + self._root = root + self._view_model = view_model + self._view_model.bind_ui(self._copy_to_clipboard, self._confirm_save) + root.title(constants.WINDOW_TITLE) + root.config(padx=constants.WINDOW_PADDING, pady=constants.WINDOW_PADDING) + self._build_logo() + self._build_form() + + @property + def website_entry(self) -> tk.Entry: + """@return The website input.""" + return self._website_entry + + @property + def email_entry(self) -> tk.Entry: + """@return The e-mail input.""" + return self._email_entry + + @property + def password_entry(self) -> tk.Entry: + """@return The password input.""" + return self._password_entry + + def generate_password(self) -> None: + """@brief Fill the password field with a new password (and copy it).""" + password = self._view_model.generate_password() + self._password_entry.delete(0, tk.END) + self._password_entry.insert(0, password) + + def save(self) -> None: + """@brief Ask the view model to store the entered details.""" + result = self._view_model.save( + self._website_entry.get(), + self._email_entry.get(), + self._password_entry.get(), + ) + if result is None: + return + self._show(result) + if result.clear_fields: + self._clear_fields() + + def find_password(self) -> None: + """@brief Show the stored details of the entered website.""" + self._show(self._view_model.find_password(self._website_entry.get())) + + def _build_logo(self) -> None: + size = constants.LOGO_SIZE + canvas = tk.Canvas( + self._root, + width=size, + height=size, + bg=constants.LOGO_BACKGROUND, + highlightthickness=0, + ) + canvas.create_arc( + *constants.LOGO_SHACKLE_BOX, + start=0, + extent=180, + style=tk.ARC, + outline=constants.LOGO_SHACKLE_COLOR, + width=constants.LOGO_SHACKLE_WIDTH, + ) + canvas.create_rectangle( + *constants.LOGO_BODY_BOX, + fill=constants.LOGO_BODY_COLOR, + outline=constants.LOGO_BODY_COLOR, + ) + canvas.create_oval( + *constants.LOGO_KEYHOLE_BOX, + fill=constants.LOGO_KEYHOLE_COLOR, + outline=constants.LOGO_KEYHOLE_COLOR, + ) + canvas.create_rectangle( + *constants.LOGO_KEYHOLE_SLOT, + fill=constants.LOGO_KEYHOLE_COLOR, + outline=constants.LOGO_KEYHOLE_COLOR, + ) + canvas.grid(row=0, column=1) + + def _build_form(self) -> None: + root = self._root + tk.Label(root, text=constants.LABEL_WEBSITE).grid(row=1, column=0) + tk.Label(root, text=constants.LABEL_EMAIL).grid(row=2, column=0) + tk.Label(root, text=constants.LABEL_PASSWORD).grid(row=3, column=0) + + self._website_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW) + self._website_entry.grid(row=1, column=1, sticky="EW") + self._website_entry.focus() + self._email_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_WIDE) + self._email_entry.grid(row=2, column=1, columnspan=2, sticky="EW") + self._email_entry.insert(0, constants.DEFAULT_EMAIL) + self._password_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW) + self._password_entry.grid(row=3, column=1, sticky="EW") + + self._search_button = tk.Button( + root, text=constants.BUTTON_SEARCH, command=self.find_password + ) + self._search_button.grid(row=1, column=2, sticky="EW") + self._generate_button = tk.Button( + root, text=constants.BUTTON_GENERATE, command=self.generate_password + ) + self._generate_button.grid(row=3, column=2, sticky="EW") + self._add_button = tk.Button( + root, + text=constants.BUTTON_ADD, + width=constants.ENTRY_WIDTH_WIDE, + command=self.save, + ) + self._add_button.grid(row=4, column=1, columnspan=2, sticky="EW") + + def _copy_to_clipboard(self, text: str) -> None: + self._root.clipboard_clear() + self._root.clipboard_append(text) + + @staticmethod + def _confirm_save(message: str) -> bool: + return bool( + messagebox.askokcancel(title=constants.WINDOW_TITLE, message=message) + ) + + @staticmethod + def _show(result: view_model_module.ActionResult) -> None: + if result.is_error: + messagebox.showwarning(title=result.title, message=result.message) + else: + messagebox.showinfo(title=result.title, message=result.message) + + def _clear_fields(self) -> None: + self._website_entry.delete(0, tk.END) + self._password_entry.delete(0, tk.END) diff --git a/src/password_manager/presentation/features/password_manager/password_manager_view_model.py b/src/password_manager/presentation/features/password_manager/password_manager_view_model.py new file mode 100644 index 0000000..01ef8b7 --- /dev/null +++ b/src/password_manager/presentation/features/password_manager/password_manager_view_model.py @@ -0,0 +1,154 @@ +"""@brief View model of the password manager screen (no tkinter here). + +@file password_manager_view_model.py +""" + +from collections.abc import Callable +from dataclasses import dataclass + +from password_manager import constants +from password_manager.application.use_cases import ( + FindPasswordEntry, + GeneratePassword, + SavePasswordEntry, +) +from password_manager.domain.errors import ( + DataFileNotFoundError, + EmptyFieldError, + EntryNotFoundError, + InvalidDataFileError, +) + + +@dataclass(frozen=True, slots=True) +class ActionResult: + """@brief What the view shows after an action.""" + + title: str ##< Title of the message box. + message: str ##< Text of the message box. + is_error: bool ##< True when the box is a warning rather than information. + clear_fields: bool = False ##< True when the view clears website and password. + + +def _ignore_clipboard(text: str) -> None: + """@brief Default clipboard: discards the text until the view binds one.""" + + +def _decline_save(message: str) -> bool: + """@brief Default confirmation: declines until the view binds a dialog.""" + return False + + +class PasswordManagerViewModel: + """@brief Data and commands of the password manager screen.""" + + def __init__( + self, + save_entry: SavePasswordEntry, + find_entry: FindPasswordEntry, + generate: GeneratePassword, + copy_to_clipboard: Callable[[str], None] = _ignore_clipboard, + confirm_save: Callable[[str], bool] = _decline_save, + ) -> None: + """@brief Create the view model. + + @param save_entry Use case that stores an entry. + @param find_entry Use case that looks an entry up. + @param generate Use case that generates a password. + @param copy_to_clipboard Puts text on the clipboard. + @param confirm_save Asks the user to confirm a save; True means ok. + """ + self._save_entry = save_entry + self._find_entry = find_entry + self._generate = generate + self._copy_to_clipboard = copy_to_clipboard + self._confirm_save = confirm_save + + def bind_ui( + self, + copy_to_clipboard: Callable[[str], None], + confirm_save: Callable[[str], bool], + ) -> None: + """@brief Hand the view's clipboard and confirmation dialog to the model. + + @param copy_to_clipboard Puts text on the clipboard. + @param confirm_save Asks the user to confirm a save; True means ok. + """ + self._copy_to_clipboard = copy_to_clipboard + self._confirm_save = confirm_save + + def generate_password(self) -> str: + """@brief Generate a password and copy it to the clipboard. + + @return The generated password. + """ + password = self._generate.execute() + self._copy_to_clipboard(password) + return password + + def save(self, website: str, email: str, password: str) -> ActionResult | None: + """@brief Confirm and store the entry. + + @param website Website field. + @param email E-mail field. + @param password Password field. + @return A result to show, or None when the user cancelled. + """ + if not (website.strip() and email.strip() and password.strip()): + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True + ) + question = constants.MESSAGE_CONFIRM_SAVE.format(email=email, password=password) + if not self._confirm_save(question): + return None + try: + self._save_entry.execute(website, email, password) + except EmptyFieldError: + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True + ) + except InvalidDataFileError: + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True + ) + else: + return ActionResult( + constants.TITLE_SAVED, + constants.MESSAGE_SAVED.format(website=website.strip()), + False, + clear_fields=True, + ) + + def find_password(self, website: str) -> ActionResult: + """@brief Look up the credentials of a website. + + @param website Website field. + @return The result to show. + """ + try: + entry = self._find_entry.execute(website) + except EmptyFieldError: + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True + ) + except DataFileNotFoundError: + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_NO_DATA_FILE, True + ) + except EntryNotFoundError as error: + return ActionResult( + constants.TITLE_OOPS, + constants.MESSAGE_NO_DETAILS.format(website=error.website), + True, + ) + except InvalidDataFileError: + return ActionResult( + constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True + ) + return ActionResult( + entry.website, + constants.MESSAGE_DETAILS.format( + email=entry.email, password=entry.password + ), + False, + )