feat: domain, use cases and JSON repository with file error handling
Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""@brief Ports the use cases need from the outside.
|
||||||
|
|
||||||
|
@file ports.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from password_manager.domain.password_entry import PasswordEntry
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRepository(Protocol):
|
||||||
|
"""@brief Storage of password entries."""
|
||||||
|
|
||||||
|
def add(self, entry: PasswordEntry) -> None:
|
||||||
|
"""@brief Store an entry, keeping every other entry.
|
||||||
|
|
||||||
|
@param entry The entry to store; replaces one for the same website.
|
||||||
|
@throws InvalidDataFileError The stored data has an unexpected format.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def find(self, website: str) -> PasswordEntry:
|
||||||
|
"""@brief Look an entry up.
|
||||||
|
|
||||||
|
@param website The website to look for.
|
||||||
|
@return The stored entry.
|
||||||
|
@throws DataFileNotFoundError There is no storage yet.
|
||||||
|
@throws EntryNotFoundError The website has no entry.
|
||||||
|
@throws InvalidDataFileError The stored data has an unexpected format.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""@brief Use cases of the password manager.
|
||||||
|
|
||||||
|
@file use_cases.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from password_manager.application.ports import PasswordRepository
|
||||||
|
from password_manager.domain.errors import EmptyFieldError
|
||||||
|
from password_manager.domain.password_entry import PasswordEntry
|
||||||
|
from password_manager.domain.password_generator import generate_password
|
||||||
|
|
||||||
|
|
||||||
|
class SavePasswordEntry:
|
||||||
|
"""@brief Validate and store a password entry."""
|
||||||
|
|
||||||
|
def __init__(self, repository: PasswordRepository) -> None:
|
||||||
|
"""@brief Create the use case.
|
||||||
|
|
||||||
|
@param repository Where entries are stored.
|
||||||
|
"""
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def execute(self, website: str, email: str, password: str) -> None:
|
||||||
|
"""@brief Store the entry.
|
||||||
|
|
||||||
|
@param website Website of the entry.
|
||||||
|
@param email E-mail address or user name.
|
||||||
|
@param password The password.
|
||||||
|
@throws EmptyFieldError A field is empty.
|
||||||
|
"""
|
||||||
|
if not (website.strip() and email.strip() and password.strip()):
|
||||||
|
raise EmptyFieldError
|
||||||
|
self._repository.add(PasswordEntry(website.strip(), email, password))
|
||||||
|
|
||||||
|
|
||||||
|
class FindPasswordEntry:
|
||||||
|
"""@brief Look up the entry of a website."""
|
||||||
|
|
||||||
|
def __init__(self, repository: PasswordRepository) -> None:
|
||||||
|
"""@brief Create the use case.
|
||||||
|
|
||||||
|
@param repository Where entries are stored.
|
||||||
|
"""
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def execute(self, website: str) -> PasswordEntry:
|
||||||
|
"""@brief Find the entry.
|
||||||
|
|
||||||
|
@param website Website to look for.
|
||||||
|
@return The stored entry.
|
||||||
|
@throws EmptyFieldError The website is empty.
|
||||||
|
@throws DataFileNotFoundError There is no storage yet.
|
||||||
|
@throws EntryNotFoundError The website has no entry.
|
||||||
|
"""
|
||||||
|
if not website.strip():
|
||||||
|
raise EmptyFieldError
|
||||||
|
return self._repository.find(website.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratePassword:
|
||||||
|
"""@brief Produce a new random password."""
|
||||||
|
|
||||||
|
def __init__(self, rng: random.Random | None = None) -> None:
|
||||||
|
"""@brief Create the use case.
|
||||||
|
|
||||||
|
@param rng Random source; secure by default.
|
||||||
|
"""
|
||||||
|
self._rng = rng
|
||||||
|
|
||||||
|
def execute(self) -> str:
|
||||||
|
"""@brief Generate a password.
|
||||||
|
|
||||||
|
@return The new password.
|
||||||
|
"""
|
||||||
|
return generate_password(self._rng)
|
||||||
@@ -16,6 +16,7 @@ DATA_FILE_PATH = Path(
|
|||||||
JSON_INDENT = 4 ##< Indentation of the written JSON, for readability.
|
JSON_INDENT = 4 ##< Indentation of the written JSON, for readability.
|
||||||
KEY_EMAIL = "email" ##< JSON key of an entry's e-mail address.
|
KEY_EMAIL = "email" ##< JSON key of an entry's e-mail address.
|
||||||
KEY_PASSWORD = "password" ##< JSON key of an entry's password.
|
KEY_PASSWORD = "password" ##< JSON key of an entry's password.
|
||||||
|
FILE_ENCODING = "utf-8" ##< Text encoding of the data file.
|
||||||
|
|
||||||
# Password generator
|
# Password generator
|
||||||
LETTERS = string.ascii_letters ##< Letters a password may use.
|
LETTERS = string.ascii_letters ##< Letters a password may use.
|
||||||
@@ -36,6 +37,17 @@ DEFAULT_EMAIL = "name@example.com" ##< E-mail address the form starts with.
|
|||||||
ENTRY_WIDTH_WIDE = 35 ##< Width in characters of the e-mail and add fields.
|
ENTRY_WIDTH_WIDE = 35 ##< Width in characters of the e-mail and add fields.
|
||||||
ENTRY_WIDTH_NARROW = 21 ##< Width in characters of the website and password fields.
|
ENTRY_WIDTH_NARROW = 21 ##< Width in characters of the website and password fields.
|
||||||
|
|
||||||
|
# Logo (a padlock drawn with canvas shapes)
|
||||||
|
LOGO_BACKGROUND = "white" ##< Canvas background colour.
|
||||||
|
LOGO_BODY_COLOR = "#2b6cb0" ##< Fill of the padlock body.
|
||||||
|
LOGO_SHACKLE_COLOR = "#4a5568" ##< Outline colour of the padlock shackle.
|
||||||
|
LOGO_KEYHOLE_COLOR = "white" ##< Fill of the keyhole.
|
||||||
|
LOGO_SHACKLE_WIDTH = 12 ##< Line width of the shackle, in pixels.
|
||||||
|
LOGO_BODY_BOX = (50, 100, 150, 180) ##< Padlock body rectangle (x0, y0, x1, y1).
|
||||||
|
LOGO_SHACKLE_BOX = (65, 30, 135, 170) ##< Bounding box of the shackle arc.
|
||||||
|
LOGO_KEYHOLE_BOX = (92, 125, 108, 141) ##< Bounding box of the keyhole circle.
|
||||||
|
LOGO_KEYHOLE_SLOT = (97, 138, 103, 160) ##< Rectangle of the keyhole slot.
|
||||||
|
|
||||||
# Labels and messages
|
# Labels and messages
|
||||||
LABEL_WEBSITE = "Website:"
|
LABEL_WEBSITE = "Website:"
|
||||||
LABEL_EMAIL = "Email/Username:"
|
LABEL_EMAIL = "Email/Username:"
|
||||||
@@ -54,3 +66,5 @@ MESSAGE_CONFIRM_SAVE = (
|
|||||||
"Password: {password}\nIs it ok to save?"
|
"Password: {password}\nIs it ok to save?"
|
||||||
)
|
)
|
||||||
MESSAGE_DETAILS = "Email: {email}\nPassword: {password}"
|
MESSAGE_DETAILS = "Email: {email}\nPassword: {password}"
|
||||||
|
MESSAGE_INVALID_DATA_FILE = "The data file has an unexpected format."
|
||||||
|
MESSAGE_SAVED = "Details for {website} were saved."
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""@brief Errors raised by the business rules of the password manager.
|
||||||
|
|
||||||
|
@file errors.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordManagerError(Exception):
|
||||||
|
"""@brief Base class of every error the password manager raises on purpose."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyFieldError(PasswordManagerError):
|
||||||
|
"""@brief A required field was left empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileNotFoundError(PasswordManagerError):
|
||||||
|
"""@brief The data file does not exist, so nothing can be looked up."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidDataFileError(PasswordManagerError):
|
||||||
|
"""@brief The data file exists but does not hold the expected structure."""
|
||||||
|
|
||||||
|
|
||||||
|
class EntryNotFoundError(PasswordManagerError):
|
||||||
|
"""@brief No entry is stored for a website."""
|
||||||
|
|
||||||
|
def __init__(self, website: str) -> None:
|
||||||
|
"""@brief Create the error.
|
||||||
|
|
||||||
|
@param website The website that was looked up.
|
||||||
|
"""
|
||||||
|
super().__init__(website)
|
||||||
|
self.website = website
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""@brief The password entry of the domain.
|
||||||
|
|
||||||
|
@file password_entry.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PasswordEntry:
|
||||||
|
"""@brief The credentials stored for one website."""
|
||||||
|
|
||||||
|
website: str ##< The website the credentials belong to.
|
||||||
|
email: str ##< E-mail address or user name.
|
||||||
|
password: str ##< The password.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""@brief Generation of random passwords.
|
||||||
|
|
||||||
|
@file password_generator.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from password_manager import constants
|
||||||
|
|
||||||
|
|
||||||
|
def generate_password(rng: random.Random | None = None) -> str:
|
||||||
|
"""@brief Build a shuffled password of letters, symbols and digits.
|
||||||
|
|
||||||
|
@param rng Random source; a cryptographically secure one when omitted.
|
||||||
|
@return The generated password; each class count is within its bounds.
|
||||||
|
"""
|
||||||
|
source = rng if rng is not None else random.SystemRandom()
|
||||||
|
characters = (
|
||||||
|
_pick(source, constants.LETTERS, constants.MIN_LETTERS, constants.MAX_LETTERS)
|
||||||
|
+ _pick(source, constants.SYMBOLS, constants.MIN_SYMBOLS, constants.MAX_SYMBOLS)
|
||||||
|
+ _pick(source, constants.DIGITS, constants.MIN_DIGITS, constants.MAX_DIGITS)
|
||||||
|
)
|
||||||
|
source.shuffle(characters)
|
||||||
|
return "".join(characters)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(source: random.Random, alphabet: str, low: int, high: int) -> list[str]:
|
||||||
|
count = source.randint(low, high)
|
||||||
|
return [source.choice(alphabet) for _ in range(count)]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""@brief Password repository that keeps entries in a JSON file.
|
||||||
|
|
||||||
|
@file json_password_repository.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from password_manager import constants
|
||||||
|
from password_manager.domain.errors import (
|
||||||
|
DataFileNotFoundError,
|
||||||
|
EntryNotFoundError,
|
||||||
|
InvalidDataFileError,
|
||||||
|
)
|
||||||
|
from password_manager.domain.password_entry import PasswordEntry
|
||||||
|
|
||||||
|
|
||||||
|
class JsonPasswordRepository:
|
||||||
|
"""@brief Stores entries as {website: {"email": ..., "password": ...}}."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
"""@brief Create the repository.
|
||||||
|
|
||||||
|
@param path Location of the JSON file.
|
||||||
|
"""
|
||||||
|
self._path = path
|
||||||
|
|
||||||
|
def add(self, entry: PasswordEntry) -> None:
|
||||||
|
"""@brief Store an entry; a missing or empty file is created or rewritten.
|
||||||
|
|
||||||
|
@param entry The entry to store.
|
||||||
|
@throws InvalidDataFileError The file holds JSON that is not an object.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._load()
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
data[entry.website] = {
|
||||||
|
constants.KEY_EMAIL: entry.email,
|
||||||
|
constants.KEY_PASSWORD: entry.password,
|
||||||
|
}
|
||||||
|
with self._path.open("w", encoding=constants.FILE_ENCODING, newline="\n") as f:
|
||||||
|
json.dump(data, f, indent=constants.JSON_INDENT)
|
||||||
|
|
||||||
|
def find(self, website: str) -> PasswordEntry:
|
||||||
|
"""@brief Look an entry up.
|
||||||
|
|
||||||
|
@param website Website to look for.
|
||||||
|
@return The stored entry.
|
||||||
|
@throws DataFileNotFoundError The file does not exist.
|
||||||
|
@throws EntryNotFoundError The website has no entry (or the file is empty).
|
||||||
|
@throws InvalidDataFileError The file has an unexpected structure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._load()
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
raise DataFileNotFoundError from error
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise EntryNotFoundError(website) from error
|
||||||
|
if website not in data:
|
||||||
|
raise EntryNotFoundError(website)
|
||||||
|
details = data[website]
|
||||||
|
try:
|
||||||
|
return PasswordEntry(
|
||||||
|
website,
|
||||||
|
details[constants.KEY_EMAIL],
|
||||||
|
details[constants.KEY_PASSWORD],
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError) as error:
|
||||||
|
raise InvalidDataFileError from error
|
||||||
|
|
||||||
|
# Any: the values of a JSON object are unchecked until `find` validates them.
|
||||||
|
def _load(self) -> dict[str, Any]:
|
||||||
|
with self._path.open("r", encoding=constants.FILE_ENCODING) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise InvalidDataFileError
|
||||||
|
return data
|
||||||
Reference in New Issue
Block a user