From a0c71670972e24ea3a171cadad874939431efa76 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:23:35 +0200 Subject: [PATCH 01/11] refactor(cli): gather all definitions and them print them all once --- src/wordly/cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wordly/cli.py b/src/wordly/cli.py index b7ea848..60b7329 100644 --- a/src/wordly/cli.py +++ b/src/wordly/cli.py @@ -13,11 +13,11 @@ from wordly.client import DictClient -async def print_definition(word: str, hostname: str, port: int) -> None: +async def print_definition(word: str, hostname: str, port: int) -> str: """Print definitions to stdout.""" async with DictClient(hostname=hostname, port=port) as client: response = await client.define(word) - print(response.definition) + return response.definition async def main(argv: Sequence[str] | None = None) -> None: @@ -52,4 +52,5 @@ async def main(argv: Sequence[str] | None = None) -> None: print_definition(word, args["hostname"], args["port"]) for word in args["words"] ] - await asyncio.gather(*tasks) + definitions = await asyncio.gather(*tasks) + print(*definitions) From 6312a6a14612f59ad148883264e487ed5fbbfb00 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:45:19 +0200 Subject: [PATCH 02/11] chore(ci): update pre-commit hooks --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 981776a..8a697e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: types: [python] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-yaml - id: trailing-whitespace @@ -24,7 +24,7 @@ repos: - id: check-added-large-files - repo: https://github.com/commitizen-tools/commitizen - rev: v4.6.0 + rev: v4.9.1 hooks: - id: commitizen stages: [commit-msg] From c605e37400c4e5e9f5469bb1c62750f60fa9b5c2 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:46:07 +0200 Subject: [PATCH 03/11] chore(ci): add a simple makefile for now --- Makefile | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5316b51 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + uv run -m unittest -v From e8f805c62f3d54f07a1914c404dd261e868876df Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:46:41 +0200 Subject: [PATCH 04/11] chore(ci): format file --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce768bf..8687df6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: + python-version: - 3.11 - 3.12 - 3.13 From b25129c502397dc9b36873a647ba4148c57223be Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:46:58 +0200 Subject: [PATCH 05/11] chore(ci): remove warning for linter rule --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ea1355d..9c500e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ fix = true [tool.ruff.lint] extend-select = [ + "ANN", # flake8 annotations "F", # Pyflakes "B", # flake8-bugbear "I", # flake8-isort & isort @@ -87,7 +88,6 @@ extend-select = [ ignore = [ "D106", # Missing docstring in public nested class "S310", # Audit URL open for permitted schemes. Allowing use of `file:` or custom schemes is often unexpected. - "UP038", # union separated is instance type checks are slower so will be ignored ] [tool.ruff.lint.flake8-tidy-imports] From 77bcf2eed2c698a5a6991e8de01a6f9fa314e2d4 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:48:45 +0200 Subject: [PATCH 06/11] refactor: provide some type hints --- src/wordly/__main__.py | 2 +- src/wordly/client.py | 31 ++++++++++++++++++++++--------- src/wordly/parser.py | 7 +++---- src/wordly/status_codes.py | 3 ++- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/wordly/__main__.py b/src/wordly/__main__.py index 5bdb86e..3e9b649 100644 --- a/src/wordly/__main__.py +++ b/src/wordly/__main__.py @@ -7,7 +7,7 @@ from wordly.cli import main -def run(): +def run() -> None: """Run async cli.""" asyncio.run(main()) diff --git a/src/wordly/client.py b/src/wordly/client.py index 4f0b08f..882a7bc 100644 --- a/src/wordly/client.py +++ b/src/wordly/client.py @@ -3,38 +3,51 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING from wordly.parser import DictParser from wordly.status_codes import Status +if TYPE_CHECKING: + from asyncio import StreamReader, StreamWriter + from types import TracebackType + from typing import Self + class DictClient: """Client.""" - def __init__(self, hostname: str = "dict.org", port: int = 2628) -> None: + def __init__( + self, hostname: str = "dict.org", port: int = 2628, READ_BYTES: int = 1024 + ) -> None: """Initialize.""" self.hostname = hostname self.port = port self.line_reader = DictParser() self.parsers = [self.line_reader] - self.reader: asyncio.StreamReader | None = None - self.writer: asyncio.StreamWriter | None = None - self.READ_BYTES = 1024 + self.reader: StreamReader | None = None + self.writer: StreamWriter | None = None + self.READ_BYTES = READ_BYTES def __repr__(self) -> str: - """Return string representation of object.""" + """Return string representation of `DictClient`.""" return f"{self.__class__.__name__}({self.hostname=}, {self.port=})" - async def __aenter__(self): + async def __aenter__(self) -> Self: """Enter method for async context manager.""" await self.connect() return self - async def __aexit__(self, exc_type, exc, tb): + async def __aexit__( + self, + exc_type: type[BaseException], + exc: type[BaseException], + tb: type[TracebackType], + ) -> None: """Exit method for async context manager.""" await self.disconnect() - async def connect(self): + async def connect(self) -> tuple[StreamReader, StreamWriter]: """Upon successful connection a status code of 220 is expected.""" self.reader, self.writer = await asyncio.open_connection( self.hostname, self.port @@ -47,7 +60,7 @@ async def connect(self): raise ConnectionError(f"Could not connect to: {self.hostname=}, {self.port=}") - async def disconnect(self): + async def disconnect(self) -> None: """Close client connection.""" self.writer.write(b"QUIT\r\n") await self.writer.drain() diff --git a/src/wordly/parser.py b/src/wordly/parser.py index fe94470..ce5a5f6 100644 --- a/src/wordly/parser.py +++ b/src/wordly/parser.py @@ -13,13 +13,13 @@ class DictParser: Creates a map of DICT status code and associated information. """ - def __init__(self, delimiter: bytes = b"\r\n"): + def __init__(self, delimiter: bytes = b"\r\n") -> None: """Initialize.""" self.line = bytearray() self.mapping = defaultdict(bytearray) self.DELIMITER = delimiter - def _process_line(self, ending: bytes = b""): + def _process_line(self, ending: bytes = b"") -> None: """Process line.""" code = self.line[:3] status = Status.by_value(bytes(code)) @@ -34,7 +34,7 @@ def _process_line(self, ending: bytes = b""): buf.extend(data) buf.extend(ending) - def feed(self, stream: bytes): + def feed(self, stream: bytes) -> None: """Feed stream of `bytes` to line reader. Calls `_process_line` on bytes stream until delimiter @@ -57,4 +57,3 @@ def feed(self, stream: bytes): def definition(self) -> str: """Return the definition of a term from parsed content.""" return self.mapping.get(Status.DEFINITION.name, b"").decode() - diff --git a/src/wordly/status_codes.py b/src/wordly/status_codes.py index a92983b..e26f435 100644 --- a/src/wordly/status_codes.py +++ b/src/wordly/status_codes.py @@ -4,12 +4,13 @@ from enum import ReprEnum, unique from functools import cache +from typing import Self class BytesEnum(bytes, ReprEnum): """Enum where members are also (and must be) `bytes`.""" - def __new__(cls, *values): + def __new__(cls, *values: bytes) -> Self: """Values must be already of type `bytes`.""" if any(not isinstance(value, bytes) for value in values): raise TypeError(f"All values must be of type `bytes`: got {values}") From 4809c531c7df63a1df68f9813100d240763b2d7e Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:49:20 +0200 Subject: [PATCH 07/11] chore: remove file in favor of files --- tests/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4692ce --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Test configurations and helper functions.""" + +from __future__ import annotations + +from pathlib import Path + +FIXTURE_DIR = Path(__file__).parent / "fixtures" + + +def load_fixture(filename: str, *, fixture_dir: Path = FIXTURE_DIR) -> bytes: + """Return fixture file from default directory.""" + return (fixture_dir / filename).read_bytes() From e95d7a19b7bcdf0b401efd97695242ccb5bc72f1 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:49:46 +0200 Subject: [PATCH 08/11] chore: add some fixtures for testing in separate directory --- tests/fixtures/help_output.txt | 33 +++++++++++++++++++++++ tests/fixtures/programming_definition.txt | 13 +++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/fixtures/help_output.txt create mode 100644 tests/fixtures/programming_definition.txt diff --git a/tests/fixtures/help_output.txt b/tests/fixtures/help_output.txt new file mode 100644 index 0000000..ce4ec3b --- /dev/null +++ b/tests/fixtures/help_output.txt @@ -0,0 +1,33 @@ +220 banner info from example.org +113 help text follows +DEFINE databaseword -- look up word in database +MATCH database strategy word --match word in database using strategy +SHOW DB -- list all accessible databases +SHOWDATABASES -- list allaccessible databases +SHOW STRAT -- list available matching strategies +SHOW STRATEGIES -- list available matching strategies +SHOW INFO database -- provide information about the database +SHOW SERVER -- provide site-specific information +OPTION MIME -- use MIME headers +CLIENT info-- identify client to server +AUTH userstring -- provide authentication information +STATUS -- display timing information +HELP -- display this help information +QUIT -- terminate connection + +The following commands are unofficial server extensions for debugging +only. You may find them useful if you are using telnet as a client. +If you are writing a client, you MUST NOT use these commands, since +they won't be supported on any other server! + +D word -- DEFINE * word +D database word -- DEFINE database word +M word -- MATCH * . word +M strategy word -- MATCH * strategy word +M database strategy word -- MATCH database strategy word +S -- STATUS +H -- HELP +Q -- QUIT +. +250 ok +221 bye [d/m/c = 0/0/0; 0.000r 0.000u 0.000s] \ No newline at end of file diff --git a/tests/fixtures/programming_definition.txt b/tests/fixtures/programming_definition.txt new file mode 100644 index 0000000..e9525b2 --- /dev/null +++ b/tests/fixtures/programming_definition.txt @@ -0,0 +1,13 @@ +220 banner information contained here +250 ok +150 1 definitions retrieved +151 "programming" wn "WordNet (r) 3.0 (2006)" +programming + n 1: setting an order and time for planned events [syn: + {scheduling}, {programming}, {programing}] + 2: creating a sequence of instructions to enable the computer to + do something [syn: {programming}, {programing}, {computer + programming}, {computer programing}] +. +250 ok [d/m/c = 1/0/33; 0.000r 0.000u 0.000s] +221 bye [d/m/c = 0/0/0; 0.000r 0.000u 0.000s] \ No newline at end of file From e162236f49ced432e2c1ff9c82fa0bf14fdeb904 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:50:40 +0200 Subject: [PATCH 09/11] chore: add simple cli test --- tests/constants.py | 57 ---------------------------------------------- tests/test_cli.py | 27 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 57 deletions(-) delete mode 100644 tests/constants.py create mode 100644 tests/test_cli.py diff --git a/tests/constants.py b/tests/constants.py deleted file mode 100644 index 822f50d..0000000 --- a/tests/constants.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Constants used for testing.""" - -from __future__ import annotations - -HELP_OUTPUT = ( - b"220 banner info from example.org\r\n" - b"113 help text follows\r\nDEFINE database" - b"word -- look up word in database" - b"\r\nMATCH database strategy word --" - b"match word in database using strategy" - b"\r\nSHOW DB -- " - b"list all accessible databases\r\nSHOW" - b"DATABASES -- list all" - b"accessible databases\r\nSHOW STRAT" - b" -- list available matching strategies" - b"\r\nSHOW STRATEGIES --" - b" list available matching strategies\r\n" - b"SHOW INFO database -- provide" - b" information about the database\r\nSHOW" - b" SERVER -- provide " - b"site-specific information\r\nOPTION MIME" - b" -- use MIME headers\r\nCLIENT info" - b"-- identify client to server\r\nAUTH user" - b"string -- provide authentication" - b" information\r\nSTATUS " - b"-- display timing information\r\nHELP " - b"-- display this help information\r\nQUIT" - b" -- terminate connection\r\n\r\nThe following" - b" commands are unofficial server extensions for" - b" debugging\r\nonly. You may find them useful" - b" if you are using telnet as a client.\r\nIf" - b" you are writing a client, you MUST NOT use" - b" these commands, since\r\nthey won't be supported" - b" on any other server!\r\n\r\nD word " - b" -- DEFINE * word\r\nD database word " - b" -- DEFINE database word\r\nM word " - b" -- MATCH * . word\r\nM strategy word " - b" -- MATCH * strategy word\r\nM database strategy word" - b" -- MATCH database strategy word\r\nS " - b" -- STATUS\r\nH " - b"-- HELP\r\nQ " - b"-- QUIT\r\n.\r\n250 ok\r\n221 bye [d/m/c =" - b" 0/0/0; 0.000r 0.000u 0.000s]" -) - -PROGRAMMING_DEFINITION = ( - b"220 banner information contained here\r\n250 ok\r\n" - b'150 1 definitions retrieved\r\n151 "programming" wn ' - b'"WordNet (r) 3.0 (2006)"\r\nprogramming\r\n n 1: ' - b"setting an order and time for planned events [syn:\r\n" - b" {scheduling}, {programming}, {programing}]\r\n" - b" 2: creating a sequence of instructions to enable " - b"the computer to\r\n do something [syn: {programming}" - b", {programing}, {computer\r\n programming}, {computer " - b"programing}]\r\n.\r\n250 ok [d/m/c = 1/0/33; 0.000r 0.000u " - b"0.000s]\r\n221 bye [d/m/c = 0/0/0; 0.000r 0.000u 0.000s]" -) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7ad93ff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,27 @@ +"""Test CLI commands.""" + +from __future__ import annotations + +import sys +import errno +import subprocess +import textwrap +import unittest + +USAGE_TEXT = textwrap.dedent("""\ + usage: Wordly [-h] [-v] [-p PORT] [-H HOSTNAME] words [words ...] + Wordly: error: the following arguments are required: words +""") + + +class TestCLI(unittest.TestCase): + """CLI test case.""" + + def test_argument_required(self) -> None: + """Should return usage text if no argument is provided.""" + result = subprocess.run( + [sys.executable, "-m", "wordly"], capture_output=True, text=True + ) + self.assertEqual(result.returncode, errno.ENOENT) + self.assertEqual("", result.stdout) + self.assertIn(USAGE_TEXT, result.stderr) From 9bdbbab031fa4b23e336b4adbcc6fd5fdad64166 Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:51:01 +0200 Subject: [PATCH 10/11] chore: add typing --- src/wordly/words.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wordly/words.py b/src/wordly/words.py index 8c6b43d..b43df67 100644 --- a/src/wordly/words.py +++ b/src/wordly/words.py @@ -4,6 +4,7 @@ import asyncio from collections import UserString +from collections.abc import Sequence from wordly.client import DictClient from wordly.status_codes import Status @@ -12,7 +13,13 @@ class Word(UserString): """`str` subclass that provides an interface for retrieving definitions of terms.""" - def __init__(self, seq, hostname: str = "dict.org", port: int = 2628, client=None): + def __init__( + self, + seq: Sequence[str], + hostname: str = "dict.org", + port: int = 2628, + client: DictClient | None = None, + ) -> None: """Initialize.""" super().__init__(seq) From fa07b3185f79da24e4e714b39dde0d73bfb5c98c Mon Sep 17 00:00:00 2001 From: MarkoM-dot <83985775+MarkoM-dot@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:51:34 +0200 Subject: [PATCH 11/11] chore: make use of test fixtures --- tests/test_parser.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/test_parser.py b/tests/test_parser.py index 8e8f219..73fe489 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -4,7 +4,7 @@ import unittest -from tests.constants import HELP_OUTPUT, PROGRAMMING_DEFINITION +from tests.conftest import load_fixture from wordly.parser import DictParser from wordly.status_codes import Status @@ -12,13 +12,19 @@ class TestParser(unittest.TestCase): """Parser tests.""" + @classmethod + def setUpClass(cls) -> None: + """Set up fixtures for all tests.""" + cls.help_output = load_fixture("help_output.txt") + cls.programming_definition = load_fixture("programming_definition.txt") + def setUp(self) -> None: """Set up fixtures for each test.""" self.line_reader = DictParser() - def test_help(self): + def test_help(self) -> None: """Should parse help byte stream.""" - self.line_reader.feed(HELP_OUTPUT) + self.line_reader.feed(self.help_output) self.assertEqual( self.line_reader.mapping[Status.INITIAL_CONNECTION.name], @@ -32,9 +38,9 @@ def test_help(self): bytearray(b"bye [d/m/c = 0/0/0; 0.000r 0.000u 0.000s]"), ) - def test_define(self): + def test_define(self) -> None: """Should parse define byte stream.""" - self.line_reader.feed(PROGRAMMING_DEFINITION) + self.line_reader.feed(self.programming_definition) self.assertEqual( self.line_reader.mapping[Status.INITIAL_CONNECTION.name],