Implement the NATO converter with tests and Doxygen comments

Task: MIL-002#2
Refs #9
Refs #10
Refs #11
Refs #12
Refs #13
This commit is contained in:
2026-10-13 16:39:03 +08:00
parent 1b7da4bcf9
commit e2321a4510
17 changed files with 467 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""@brief Entry point of the command `python -m nato_alphabet`."""
from nato_alphabet.main import main
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""@brief Adapters of the NATO alphabet tool: data file and console."""
+31
View File
@@ -0,0 +1,31 @@
"""@brief Adapter that talks to the user through the terminal."""
from nato_alphabet import constants
from nato_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,52 @@
"""@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_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)
}
class CsvAlphabetSource:
"""@brief AlphabetSource port implemented on a CSV file."""
def __init__(self, path: Traversable | None = None) -> None:
"""@brief Create the source.
@param path The CSV file; the packaged file when omitted.
"""
## @brief CSV file to read, or None for the packaged file.
self._path = path
def load(self) -> dict[str, str]:
"""@brief Read the alphabet.
@return Code word per upper-case letter.
"""
return load_nato_dict(self._path)
@@ -0,0 +1 @@
"""@brief Use case and ports of the NATO alphabet tool."""
+42
View File
@@ -0,0 +1,42 @@
"""@brief Ports the use case needs from the outside world."""
from collections.abc import Mapping
from typing import Protocol
class InputClosedError(Exception):
"""@brief Raised when the user can give no more input (end of input, abort)."""
class AlphabetSource(Protocol):
"""@brief Port that supplies the NATO alphabet."""
def load(self) -> Mapping[str, str]:
"""@brief Read the alphabet.
@return Code word per upper-case letter.
"""
...
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,42 @@
"""@brief Use case: ask for a word until it can be spelled, then show it."""
from nato_alphabet.application.ports import (
AlphabetSource,
InputClosedError,
WordConsole,
)
from nato_alphabet.domain.spelling import InvalidWordError, generate_phonetic
class SpellWordSession:
"""@brief One session of the tool: one word is spelled per session."""
def __init__(self, source: AlphabetSource, console: WordConsole) -> None:
"""@brief Create a session.
@param source Supplies the NATO alphabet.
@param console Talks to the user.
"""
## @brief Supplier of the alphabet.
self._source = source
## @brief Conversation with the user.
self._console = console
def run(self) -> None:
"""@brief Ask for words until one is valid, then show its code words.
Ends without a result when the user can give no more input.
"""
alphabet = self._source.load()
while True:
try:
word = self._console.ask_word()
except InputClosedError:
return
try:
code_words = generate_phonetic(word, alphabet)
except InvalidWordError:
self._console.show_invalid()
continue
self._console.show_code_words(code_words)
return
+6
View File
@@ -27,3 +27,9 @@ INVALID_INPUT_TEXT: str = "Sorry, only the letters A to Z are allowed. Try again
## @brief Text shown before the code words are printed.
RESULT_TEXT: str = "Phonetic code words:"
## @brief Text encoding of the data file.
DATA_ENCODING: str = "utf-8"
## @brief Exit status of a session that ended normally.
EXIT_OK: int = 0
+1
View File
@@ -0,0 +1 @@
"""@brief Business rules of the NATO alphabet tool, free of input and output."""
+30
View File
@@ -0,0 +1,30 @@
"""@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 generate_phonetic(word: str, alphabet: Mapping[str, str]) -> list[str]:
"""@brief Spell a word with the code word of each of its letters.
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
+18
View File
@@ -0,0 +1,18 @@
"""@brief Composition root: wires the adapters into the use case."""
from nato_alphabet import constants
from nato_alphabet.adapters.console import TerminalConsole
from nato_alphabet.adapters.csv_alphabet import CsvAlphabetSource, load_nato_dict
from nato_alphabet.application.spell_word_session import SpellWordSession
from nato_alphabet.domain.spelling import InvalidWordError, generate_phonetic
__all__ = ["InvalidWordError", "generate_phonetic", "load_nato_dict", "main"]
def main() -> int:
"""@brief Run the interactive tool.
@return The exit status of the program.
"""
SpellWordSession(CsvAlphabetSource(), TerminalConsole()).run()
return constants.EXIT_OK