Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use katcp-codec for encoding and decoding messages #90

Merged
merged 7 commits into from
Apr 16, 2024
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/.coverage
/.eggs
/.mypy_cache
/src/aiokatcp/_version.py
src/aiokatcp.egg-info
*.pyc
__pycache__
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ repos:
args: []
additional_dependencies: [
'async-timeout==4.0.3',
'katcp-codec==0.1.0b2',
'pytest==7.4.2',
'types-decorator==5.1.1',
'typing-extensions==4.7.1'
Expand Down
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

.. rubric:: Version 1.10.0b1

- Use `katcp-codec`_ to provide the low-level encoding and decoding of
katcp messages, yielding a significant speedup.

.. _katcp-codec: https://katcp-codec.readthedocs.io/en/latest/

.. rubric:: Version 1.9.0

- Drop support for end-of-life Python 3.7.
Expand Down
5 changes: 2 additions & 3 deletions doc/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ Introduction to aiokatcp
========================

aiokatcp is an implementation of the `katcp`_ protocol based around the Python
asyncio system module. It requires Python 3.5 or later, as it makes extensive
uses of coroutines and type annotations. It is loosely inspired by the `Python
2 bindings`_, but has a much narrower scope.
asyncio system module. It is loosely inspired by the `Python 2 bindings`_, but
has a much narrower scope.

.. _katcp: https://katcp-python.readthedocs.io/en/latest/_downloads/361189acb383a294be20d6c10c257cb4/NRF-KAT7-6.0-IFCE-002-Rev5-1.pdf

Expand Down
3 changes: 0 additions & 3 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ files = src/aiokatcp, examples, tests

[mypy-async_solipsism.*]
ignore_missing_imports = True

[mypy-katversion.*]
ignore_missing_imports = True
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
[build-system]
requires = ["setuptools", "katversion", "setuptools_scm"]
requires = ["setuptools", "setuptools_scm"]

[tool.setuptools_scm]
version_file = "src/aiokatcp/_version.py"

[tool.isort]
profile = "black"
Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ async-solipsism
async-timeout
coveralls
decorator
katcp-codec
pre-commit
pytest
pytest-asyncio
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ idna==3.7
# via requests
iniconfig==2.0.0
# via pytest
katcp-codec==0.1.0b2
# via -r requirements.in
nodeenv==1.8.0
# via pre-commit
packaging==23.1
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ packages = find:
install_requires =
async-timeout
decorator>=4.1
katcp-codec
typing-extensions
python_requires = >=3.8
zip_safe = False # For py.typed
Expand Down
30 changes: 0 additions & 30 deletions setup.py

This file was deleted.

15 changes: 2 additions & 13 deletions src/aiokatcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2017, 2022, 2023 National Research Foundation (SARAO)
# Copyright 2017, 2022-2024 National Research Foundation (SARAO)
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
Expand All @@ -25,18 +25,7 @@
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

# BEGIN VERSION CHECK
# Get package version when locally imported from repo or via -e develop install
try:
import katversion as _katversion
except ImportError:
import time as _time

__version__ = "0.0+unknown.{}".format(_time.strftime("%Y%m%d%H%M"))
else:
__version__ = _katversion.get_version(__path__[0])
# END VERSION CHECK

from ._version import __version__
from .client import ( # noqa: F401
AbstractSensorWatcher,
Client,
Expand Down
79 changes: 20 additions & 59 deletions src/aiokatcp/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@

import enum
import functools
import io
import ipaddress
import logging
import numbers
Expand All @@ -47,6 +46,9 @@
Union,
)

import katcp_codec
from typing_extensions import TypeAlias

_T = TypeVar("_T")
_E = TypeVar("_E", bound=enum.Enum)
_F = TypeVar("_F", bound=numbers.Real)
Expand Down Expand Up @@ -465,28 +467,12 @@ def __init__(self, message: str, raw: Optional[bytes] = None) -> None:
class Message:
__slots__ = ["mtype", "name", "arguments", "mid"]

class Type(enum.Enum):
"""Message type"""

REQUEST = 1
REPLY = 2
INFORM = 3

_TYPE_SYMBOLS = {
Type.REQUEST: b"?",
Type.REPLY: b"!",
Type.INFORM: b"#",
}
_REVERSE_TYPE_SYMBOLS = {value: key for (key, value) in _TYPE_SYMBOLS.items()}
Type: TypeAlias = katcp_codec.MessageType

_NAME_RE = re.compile("^[A-Za-z][A-Za-z0-9-]*$", re.ASCII)
_HEADER_RE = re.compile(rb"^[!#?]([A-Za-z][A-Za-z0-9-]*)(?:\[([1-9][0-9]*)\])?$")
#: Characters that must be escaped in an argument
_ESCAPE_RE = re.compile(rb"[\\ \0\n\r\x1b\t]")
_UNESCAPE_RE = re.compile(rb"\\(.)?") # ? so that it also matches trailing backslash
#: Characters not allowed to appear in an argument
# (space, tab are omitted because they are split on already)
_SPECIAL_RE = re.compile(rb"[\0\r\n\x1b]")

_ESCAPE_LOOKUP = {
b"\\": b"\\",
Expand Down Expand Up @@ -581,55 +567,30 @@ def parse(cls, raw) -> "Message":
If `raw` is not validly encoded.
"""
try:
if not raw or raw[:1] not in b"?#!":
raise KatcpSyntaxError("message does not start with message type")
if raw[-1:] not in (b"\r", b"\n"):
raise KatcpSyntaxError("message does not end with newline")
clean = raw[:-1].replace(b"\t", b" ")
match = cls._SPECIAL_RE.search(clean)
if match:
raise KatcpSyntaxError(f"unescaped special {match.group()!r}")
# NB: don't use split() without an argument, as it will also split
# on whitespace other than space or tab (e.g. form feed).
parts = [part for part in clean.split(b" ") if part]
match = cls._HEADER_RE.match(parts[0])
if not match:
raise KatcpSyntaxError("could not parse name and message ID")
name = match.group(1).decode("ascii")
mid_raw = match.group(2)
if mid_raw is not None:
mid = int(mid_raw)
else:
mid = None
mtype = cls._REVERSE_TYPE_SYMBOLS[clean[:1]]
raise ValueError("message does not end with newline")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not KatcpSyntaxError anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValueErrors get turned into KatcpSyntaxErrors lower down anyway, so it seemed simpler to only raise ValueError (it's what ends up in msgs[0] if the message is invalid) and not have to worry about catching KatcpSyntaxError.

parser = katcp_codec.Parser(len(raw))
msgs = parser.append(raw)
if not msgs:
raise ValueError("no message")
if len(msgs) > 1 or parser.buffer_size > 0:
raise ValueError("internal newline")
msg = msgs[0]
if isinstance(msg, Exception):
raise msg
# Create the message first without arguments, to avoid the argument
# encoding and let us store raw bytes.
msg = cls(mtype, name, mid=mid)
# Performance: copy functions to local variables to avoid doing
# attribute lookup for every element of parts.
sub = cls._UNESCAPE_RE.sub
unescape_match = cls._unescape_match
msg.arguments = [sub(unescape_match, arg) for arg in parts[1:]]
return msg
except KatcpSyntaxError as error:
error.raw = raw
raise error
ret = cls(msg.mtype, msg.name.decode("ascii"), mid=msg.mid)
ret.arguments = msg.arguments
return ret
except ValueError as error:
raise KatcpSyntaxError(str(error), raw) from error

def __bytes__(self) -> bytes:
"""Return Message as serialised for transmission"""

output = io.BytesIO()
output.write(self._TYPE_SYMBOLS[self.mtype])
output.write(self.name.encode("ascii"))
if self.mid is not None:
output.write(b"[" + str(self.mid).encode("ascii") + b"]")
for arg in self.arguments:
output.write(b" ")
output.write(self.escape_argument(arg))
output.write(b"\n")
return output.getvalue()
return bytes(
katcp_codec.Message(self.mtype, self.name.encode("ascii"), self.mid, self.arguments)
)

def __repr__(self) -> str:
return (
Expand Down
Loading