MIL-002 NATO converter #22
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""@brief Entry point of the command `python -m nato_alphabet`."""
|
||||
|
||||
from nato_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_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."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""@brief Business rules of the NATO alphabet tool, free of input and output."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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 == ""
|
||||
Reference in New Issue
Block a user