From e2321a4510ac0eb63182b976856ce8b4336ee0ba Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 16:39:03 +0800 Subject: [PATCH] Implement the NATO converter with tests and Doxygen comments Task: MIL-002#2 Refs #9 Refs #10 Refs #11 Refs #12 Refs #13 --- pyproject.toml | 12 ++++ src/nato_alphabet/__main__.py | 5 ++ src/nato_alphabet/adapters/__init__.py | 1 + src/nato_alphabet/adapters/console.py | 31 ++++++++ src/nato_alphabet/adapters/csv_alphabet.py | 52 ++++++++++++++ src/nato_alphabet/application/__init__.py | 1 + src/nato_alphabet/application/ports.py | 42 +++++++++++ .../application/spell_word_session.py | 42 +++++++++++ src/nato_alphabet/constants.py | 6 ++ src/nato_alphabet/domain/__init__.py | 1 + src/nato_alphabet/domain/spelling.py | 30 ++++++++ src/nato_alphabet/main.py | 18 +++++ tests/adapters/test_console.py | 71 +++++++++++++++++++ tests/adapters/test_csv_alphabet.py | 26 +++++++ tests/application/test_spell_word_session.py | 56 +++++++++++++++ tests/domain/test_spelling.py | 43 +++++++++++ tests/test_main_module.py | 30 ++++++++ 17 files changed, 467 insertions(+) create mode 100644 src/nato_alphabet/__main__.py create mode 100644 src/nato_alphabet/adapters/__init__.py create mode 100644 src/nato_alphabet/adapters/console.py create mode 100644 src/nato_alphabet/adapters/csv_alphabet.py create mode 100644 src/nato_alphabet/application/__init__.py create mode 100644 src/nato_alphabet/application/ports.py create mode 100644 src/nato_alphabet/application/spell_word_session.py create mode 100644 src/nato_alphabet/domain/__init__.py create mode 100644 src/nato_alphabet/domain/spelling.py create mode 100644 src/nato_alphabet/main.py create mode 100644 tests/adapters/test_console.py create mode 100644 tests/adapters/test_csv_alphabet.py create mode 100644 tests/application/test_spell_word_session.py create mode 100644 tests/domain/test_spelling.py create mode 100644 tests/test_main_module.py diff --git a/pyproject.toml b/pyproject.toml index 7b58c7c..5a5b533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,3 +53,15 @@ convention = "pep257" strict = true files = ["src", "tests"] mypy_path = "src" + +[tool.importlinter] +root_package = "nato_alphabet" + +[[tool.importlinter.contracts]] +name = "Dependencies point inward" +type = "layers" +layers = [ + "nato_alphabet.adapters", + "nato_alphabet.application", + "nato_alphabet.domain", +] diff --git a/src/nato_alphabet/__main__.py b/src/nato_alphabet/__main__.py new file mode 100644 index 0000000..e1ad60e --- /dev/null +++ b/src/nato_alphabet/__main__.py @@ -0,0 +1,5 @@ +"""@brief Entry point of the command `python -m nato_alphabet`.""" + +from nato_alphabet.main import main + +raise SystemExit(main()) diff --git a/src/nato_alphabet/adapters/__init__.py b/src/nato_alphabet/adapters/__init__.py new file mode 100644 index 0000000..4460049 --- /dev/null +++ b/src/nato_alphabet/adapters/__init__.py @@ -0,0 +1 @@ +"""@brief Adapters of the NATO alphabet tool: data file and console.""" diff --git a/src/nato_alphabet/adapters/console.py b/src/nato_alphabet/adapters/console.py new file mode 100644 index 0000000..5f4427b --- /dev/null +++ b/src/nato_alphabet/adapters/console.py @@ -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") diff --git a/src/nato_alphabet/adapters/csv_alphabet.py b/src/nato_alphabet/adapters/csv_alphabet.py new file mode 100644 index 0000000..22fab6a --- /dev/null +++ b/src/nato_alphabet/adapters/csv_alphabet.py @@ -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) diff --git a/src/nato_alphabet/application/__init__.py b/src/nato_alphabet/application/__init__.py new file mode 100644 index 0000000..1e78bbb --- /dev/null +++ b/src/nato_alphabet/application/__init__.py @@ -0,0 +1 @@ +"""@brief Use case and ports of the NATO alphabet tool.""" diff --git a/src/nato_alphabet/application/ports.py b/src/nato_alphabet/application/ports.py new file mode 100644 index 0000000..7c22d40 --- /dev/null +++ b/src/nato_alphabet/application/ports.py @@ -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. + """ + ... diff --git a/src/nato_alphabet/application/spell_word_session.py b/src/nato_alphabet/application/spell_word_session.py new file mode 100644 index 0000000..6b7cd58 --- /dev/null +++ b/src/nato_alphabet/application/spell_word_session.py @@ -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 diff --git a/src/nato_alphabet/constants.py b/src/nato_alphabet/constants.py index 296a858..fe5d9d9 100644 --- a/src/nato_alphabet/constants.py +++ b/src/nato_alphabet/constants.py @@ -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 diff --git a/src/nato_alphabet/domain/__init__.py b/src/nato_alphabet/domain/__init__.py new file mode 100644 index 0000000..5e9796f --- /dev/null +++ b/src/nato_alphabet/domain/__init__.py @@ -0,0 +1 @@ +"""@brief Business rules of the NATO alphabet tool, free of input and output.""" diff --git a/src/nato_alphabet/domain/spelling.py b/src/nato_alphabet/domain/spelling.py new file mode 100644 index 0000000..533997c --- /dev/null +++ b/src/nato_alphabet/domain/spelling.py @@ -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 diff --git a/src/nato_alphabet/main.py b/src/nato_alphabet/main.py new file mode 100644 index 0000000..346d15f --- /dev/null +++ b/src/nato_alphabet/main.py @@ -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 diff --git a/tests/adapters/test_console.py b/tests/adapters/test_console.py new file mode 100644 index 0000000..637e5bf --- /dev/null +++ b/tests/adapters/test_console.py @@ -0,0 +1,71 @@ +import pytest + +from nato_alphabet import constants +from nato_alphabet.adapters.console import TerminalConsole +from nato_alphabet.application.ports import InputClosedError +from nato_alphabet.main import main + + +def _feed(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> list[str]: + prompts: list[str] = [] + + def fake_input(prompt: str = "") -> str: + prompts.append(prompt) + if not answers: + raise EOFError + return answers.pop(0) + + monkeypatch.setattr("builtins.input", fake_input) + return prompts + + +def test_main_prints_code_words_for_valid_word( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _feed(monkeypatch, ["Hello"]) + + status = main() + + lines = capsys.readouterr().out.splitlines() + assert status == constants.EXIT_OK + assert lines == [constants.RESULT_TEXT, "Hotel", "Echo", "Lima", "Lima", "Oscar"] + + +def test_main_asks_again_after_invalid_word( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + prompts = _feed(monkeypatch, ["h3llo", "Hi"]) + + main() + + lines = capsys.readouterr().out.splitlines() + assert prompts == [constants.PROMPT_TEXT, constants.PROMPT_TEXT] + assert lines == [ + constants.INVALID_INPUT_TEXT, + constants.RESULT_TEXT, + "Hotel", + "India", + ] + + +def test_main_ends_cleanly_on_end_of_input( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _feed(monkeypatch, []) + + status = main() + + assert status == constants.EXIT_OK + assert capsys.readouterr().out == "" + + +def test_terminal_console_raises_input_closed_on_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def interrupted(prompt: str = "") -> str: + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", interrupted) + + with pytest.raises(InputClosedError): + TerminalConsole().ask_word() diff --git a/tests/adapters/test_csv_alphabet.py b/tests/adapters/test_csv_alphabet.py new file mode 100644 index 0000000..547090e --- /dev/null +++ b/tests/adapters/test_csv_alphabet.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from nato_alphabet.adapters.csv_alphabet import CsvAlphabetSource, load_nato_dict + +EXPECTED_ENTRIES = 26 + + +def test_load_nato_dict_reads_temporary_csv(tmp_path: Path) -> None: + data_file = tmp_path / "alphabet.csv" + data_file.write_text("letter,code\na,Alfa\nB,Bravo\n", encoding="utf-8") + + assert load_nato_dict(data_file) == {"A": "Alfa", "B": "Bravo"} + + +def test_load_nato_dict_builds_all_entries_from_packaged_file() -> None: + alphabet = load_nato_dict() + + assert len(alphabet) == EXPECTED_ENTRIES + assert alphabet["Z"] == "Zulu" + + +def test_csv_alphabet_source_loads_given_file(tmp_path: Path) -> None: + data_file = tmp_path / "alphabet.csv" + data_file.write_text("letter,code\nA,Alfa\n", encoding="utf-8") + + assert CsvAlphabetSource(data_file).load() == {"A": "Alfa"} diff --git a/tests/application/test_spell_word_session.py b/tests/application/test_spell_word_session.py new file mode 100644 index 0000000..c7611a9 --- /dev/null +++ b/tests/application/test_spell_word_session.py @@ -0,0 +1,56 @@ +from collections.abc import Mapping + +from nato_alphabet.application.ports import InputClosedError +from nato_alphabet.application.spell_word_session import SpellWordSession + + +class FakeSource: + def load(self) -> Mapping[str, str]: + return {"A": "Alfa", "B": "Bravo"} + + +class FakeConsole: + def __init__(self, words: list[str]) -> None: + self._words = words + self.asked = 0 + self.invalid = 0 + self.shown: list[list[str]] = [] + + def ask_word(self) -> str: + self.asked += 1 + if not self._words: + raise InputClosedError + return self._words.pop(0) + + def show_invalid(self) -> None: + self.invalid += 1 + + def show_code_words(self, code_words: list[str]) -> None: + self.shown.append(code_words) + + +def test_run_shows_code_words_for_valid_word() -> None: + console = FakeConsole(["ab"]) + + SpellWordSession(FakeSource(), console).run() + + assert console.shown == [["Alfa", "Bravo"]] + assert console.asked == 1 + + +def test_run_asks_again_after_invalid_word() -> None: + console = FakeConsole(["a1", "", "b"]) + + SpellWordSession(FakeSource(), console).run() + + assert console.invalid == 2 + assert console.asked == 3 + assert console.shown == [["Bravo"]] + + +def test_run_ends_cleanly_when_input_is_closed() -> None: + console = FakeConsole([]) + + SpellWordSession(FakeSource(), console).run() + + assert console.shown == [] diff --git a/tests/domain/test_spelling.py b/tests/domain/test_spelling.py new file mode 100644 index 0000000..4aca7cf --- /dev/null +++ b/tests/domain/test_spelling.py @@ -0,0 +1,43 @@ +import pytest + +from nato_alphabet.adapters.csv_alphabet import load_nato_dict +from nato_alphabet.domain.spelling import InvalidWordError, generate_phonetic + +ALPHABET = load_nato_dict() +LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + +@pytest.mark.parametrize("letter", list(LETTERS)) +def test_generate_phonetic_maps_letter_to_its_code_word(letter: str) -> None: + assert generate_phonetic(letter, ALPHABET) == [ALPHABET[letter]] + + +def test_generate_phonetic_uses_known_code_words() -> None: + assert generate_phonetic("ADZ", ALPHABET) == ["Alfa", "Delta", "Zulu"] + + +def test_generate_phonetic_covers_all_26_letters() -> None: + assert len(generate_phonetic(LETTERS, ALPHABET)) == len(LETTERS) + + +def test_generate_phonetic_is_case_insensitive_for_mixed_case() -> None: + assert generate_phonetic("Hello", ALPHABET) == [ + "Hotel", + "Echo", + "Lima", + "Lima", + "Oscar", + ] + + +@pytest.mark.parametrize("word", ["a1", "two words", "hi!", "", "ß", "æ"]) +def test_generate_phonetic_raises_for_non_letter_or_empty(word: str) -> None: + with pytest.raises(InvalidWordError): + generate_phonetic(word, ALPHABET) + + +def test_generate_phonetic_keeps_cause_of_invalid_character() -> None: + with pytest.raises(InvalidWordError) as info: + generate_phonetic("a1", ALPHABET) + + assert isinstance(info.value.__cause__, KeyError) diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 0000000..636a97e --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,30 @@ +import subprocess +import sys + +from nato_alphabet import constants + + +def _run_module(stdin_text: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "nato_alphabet"], + input=stdin_text, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + +def test_python_dash_m_spells_word_from_piped_input() -> None: + result = _run_module("Hi\n") + + assert result.returncode == 0 + assert constants.RESULT_TEXT in result.stdout + assert result.stdout.splitlines()[-2:] == ["Hotel", "India"] + + +def test_python_dash_m_exits_cleanly_on_end_of_input() -> None: + result = _run_module("") + + assert result.returncode == 0 + assert result.stderr == ""