Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
python-version:
- 3.11
- 3.12
- 3.13
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test:
uv run -m unittest -v
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fix = true

[tool.ruff.lint]
extend-select = [
"ANN", # flake8 annotations
"F", # Pyflakes
"B", # flake8-bugbear
"I", # flake8-isort & isort
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/wordly/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from wordly.cli import main


def run():
def run() -> None:
"""Run async cli."""
asyncio.run(main())

Expand Down
7 changes: 4 additions & 3 deletions src/wordly/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
31 changes: 22 additions & 9 deletions src/wordly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
7 changes: 3 additions & 4 deletions src/wordly/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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()

3 changes: 2 additions & 1 deletion src/wordly/status_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
9 changes: 8 additions & 1 deletion src/wordly/words.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
57 changes: 0 additions & 57 deletions tests/constants.py

This file was deleted.

33 changes: 33 additions & 0 deletions tests/fixtures/help_output.txt
Original file line number Diff line number Diff line change
@@ -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]
13 changes: 13 additions & 0 deletions tests/fixtures/programming_definition.txt
Original file line number Diff line number Diff line change
@@ -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]
27 changes: 27 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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)
Loading