MIL-002 NATO converter: modelling, converter code, tests and Doxygen comments
User story, use case and SSD for "spell a word"; spell_word with try/except KeyError, generate_phonetic with the retry loop, console and CSV adapters, 49 pytest tests; MIL-002 and the modelling documents accepted with review records. Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""@brief Entry point of the command `python -m nato_phonetic_alphabet`."""
|
||||
|
||||
from nato_phonetic_alphabet.main import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""@brief Adapters of the NATO alphabet tool: data file and console."""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""@brief Adapter that talks to the user through the terminal."""
|
||||
|
||||
from nato_phonetic_alphabet import constants
|
||||
from nato_phonetic_alphabet.application.ports import InputClosedError
|
||||
|
||||
|
||||
class TerminalConsole:
|
||||
"""@brief WordConsole port implemented on standard input and output."""
|
||||
|
||||
def ask_word(self) -> str:
|
||||
"""@brief Prompt for a word.
|
||||
|
||||
@return The word as typed.
|
||||
@throws InputClosedError Input ended or the user interrupted it.
|
||||
"""
|
||||
try:
|
||||
return input(constants.PROMPT_TEXT)
|
||||
except (EOFError, KeyboardInterrupt) as error:
|
||||
raise InputClosedError from error
|
||||
|
||||
def show_invalid(self) -> None:
|
||||
"""@brief Print that the word was rejected."""
|
||||
print(constants.INVALID_INPUT_TEXT)
|
||||
|
||||
def show_code_words(self, code_words: list[str]) -> None:
|
||||
"""@brief Print the result.
|
||||
|
||||
@param code_words The code words to print.
|
||||
"""
|
||||
print(constants.RESULT_TEXT)
|
||||
print(*code_words, sep="\n")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""@brief Adapter that reads the NATO alphabet from a CSV file."""
|
||||
|
||||
import csv
|
||||
from importlib import resources
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
from nato_phonetic_alphabet import constants
|
||||
|
||||
|
||||
def packaged_data_file() -> Traversable:
|
||||
"""@brief Locate the data file shipped with the package.
|
||||
|
||||
@return The packaged CSV file.
|
||||
"""
|
||||
return (
|
||||
resources.files(constants.DATA_PACKAGE)
|
||||
/ constants.DATA_DIRECTORY
|
||||
/ constants.DATA_FILE_NAME
|
||||
)
|
||||
|
||||
|
||||
def load_nato_dict(path: Traversable | None = None) -> dict[str, str]:
|
||||
"""@brief Read the NATO alphabet from a CSV file.
|
||||
|
||||
@param path The CSV file; the packaged file when omitted.
|
||||
@return Code word per upper-case letter.
|
||||
"""
|
||||
source = path if path is not None else packaged_data_file()
|
||||
with source.open("r", encoding=constants.DATA_ENCODING) as stream:
|
||||
return {
|
||||
row[constants.LETTER_COLUMN].upper(): row[constants.CODE_COLUMN]
|
||||
for row in csv.DictReader(stream)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""@brief Use case and ports of the NATO alphabet tool."""
|
||||
@@ -0,0 +1,26 @@
|
||||
"""@brief Use case: ask for a word until it can be spelled."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from nato_phonetic_alphabet.application.ports import WordConsole
|
||||
from nato_phonetic_alphabet.domain.spelling import InvalidWordError, spell_word
|
||||
|
||||
|
||||
def generate_phonetic(alphabet: Mapping[str, str], console: WordConsole) -> list[str]:
|
||||
"""@brief Ask for a word until it is valid, then return its code words.
|
||||
|
||||
This is the assignment's `generate_phonetic`: it wraps the input and the
|
||||
error handling. An invalid word is reported to the user and asked for
|
||||
again, so the function returns only for a valid word.
|
||||
|
||||
@param alphabet Code word per upper-case letter.
|
||||
@param console Conversation with the user.
|
||||
@return The code words of the word the user typed.
|
||||
@throws InputClosedError The user can give no more input.
|
||||
"""
|
||||
while True:
|
||||
word = console.ask_word()
|
||||
try:
|
||||
return spell_word(word, alphabet)
|
||||
except InvalidWordError:
|
||||
console.show_invalid()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""@brief Ports the use case needs from the outside world."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class InputClosedError(Exception):
|
||||
"""@brief Raised when the user can give no more input (end of input, abort)."""
|
||||
|
||||
|
||||
class WordConsole(Protocol):
|
||||
"""@brief Port for the conversation with the user."""
|
||||
|
||||
def ask_word(self) -> str:
|
||||
"""@brief Ask the user for a word.
|
||||
|
||||
@return The word as typed.
|
||||
@throws InputClosedError The user can give no more input.
|
||||
"""
|
||||
...
|
||||
|
||||
def show_invalid(self) -> None:
|
||||
"""@brief Tell the user that the word was rejected."""
|
||||
...
|
||||
|
||||
def show_code_words(self, code_words: list[str]) -> None:
|
||||
"""@brief Show the result to the user.
|
||||
|
||||
@param code_words The code words to show.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1 @@
|
||||
"""@brief Business rules of the NATO alphabet tool, free of input and output."""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""@brief Rule that spells a word with the NATO phonetic alphabet."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
class InvalidWordError(ValueError):
|
||||
"""@brief Raised when a word cannot be spelled.
|
||||
|
||||
A word is invalid when it is empty or holds a character that is not a
|
||||
letter A to Z.
|
||||
"""
|
||||
|
||||
|
||||
def spell_word(word: str, alphabet: Mapping[str, str]) -> list[str]:
|
||||
"""@brief Spell a word with the code word of each of its letters.
|
||||
|
||||
The list comprehension looks every letter up in the alphabet. A character
|
||||
that is not in the alphabet makes the lookup fail with a `KeyError`, which
|
||||
is caught here and turned into an `InvalidWordError`. The match is
|
||||
case-insensitive.
|
||||
|
||||
@param word The word to spell.
|
||||
@param alphabet Code word per upper-case letter.
|
||||
@return The code words, one per letter of the word, in order.
|
||||
@throws InvalidWordError The word is empty or holds a character that is
|
||||
not a letter of the alphabet.
|
||||
"""
|
||||
if not word:
|
||||
raise InvalidWordError("The word is empty.")
|
||||
try:
|
||||
return [alphabet[letter.upper()] for letter in word]
|
||||
except KeyError as error:
|
||||
raise InvalidWordError(f"Not a letter: {error.args[0]!r}") from error
|
||||
@@ -0,0 +1,32 @@
|
||||
"""@brief Composition root: wires the adapters into the use case."""
|
||||
|
||||
from nato_phonetic_alphabet import constants
|
||||
from nato_phonetic_alphabet.adapters.console import TerminalConsole
|
||||
from nato_phonetic_alphabet.adapters.csv_alphabet import load_nato_dict
|
||||
from nato_phonetic_alphabet.application.phonetic import generate_phonetic
|
||||
from nato_phonetic_alphabet.application.ports import InputClosedError
|
||||
from nato_phonetic_alphabet.domain.spelling import InvalidWordError, spell_word
|
||||
|
||||
__all__ = [
|
||||
"InvalidWordError",
|
||||
"generate_phonetic",
|
||||
"load_nato_dict",
|
||||
"main",
|
||||
"spell_word",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""@brief Run the interactive tool.
|
||||
|
||||
Ends without a result when the user can give no more input.
|
||||
|
||||
@return The exit status of the program.
|
||||
"""
|
||||
console = TerminalConsole()
|
||||
try:
|
||||
code_words = generate_phonetic(load_nato_dict(), console)
|
||||
except InputClosedError:
|
||||
return constants.EXIT_OK
|
||||
console.show_code_words(code_words)
|
||||
return constants.EXIT_OK
|
||||
Reference in New Issue
Block a user