Skip to content

Commit 464f05e

Browse files
asn1 app over soup (#44)
1 parent 4128920 commit 464f05e

20 files changed

+1123
-16
lines changed

.pylintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ disable=
1616
W0707, #too-many-statements
1717
R0801, #similarities
1818
R0913, #too-many-arguments
19-
R0917, #too-many-positional-arguments
19+
R0917, #too-many-positional-arguments
20+
W1203, #logging-fstring-interpolation

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ classifiers = [
1616
dependencies = [
1717
'attrs>=23.1',
1818
'chevron>=0.14.0',
19-
'click>=8.1'
19+
'click>=8.1',
20+
'asn1>=2.6',
21+
'asn1tools==0.164.0'
2022
]
2123
dynamic = ["version"]
2224

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ chevron>=0.14.0
33
click>=8.1
44
pytest>=8.3
55
pytest-cov>=4
6+
asn1>=2.6
7+
asn1tools==0.164.0
68
pytest-asyncio>=0.23
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from typing import Callable
2+
from nasdaq_protocols import soup
3+
4+
from .soup_session import (
5+
OnAns1CloseCoro,
6+
OnAsn1MessageCoro,
7+
Ans1SoupSessionId,
8+
Asn1SoupClientSession
9+
)
10+
from .core import (
11+
Asn1Message,
12+
Asn1Spec
13+
)
14+
from .codegen import generate_soup_app
15+
16+
17+
__all__ = [
18+
'Asn1Message',
19+
'Asn1Spec',
20+
'OnAns1CloseCoro',
21+
'OnAsn1MessageCoro',
22+
'Ans1SoupSessionId',
23+
'generate_soup_app',
24+
'connect_async_soup'
25+
]
26+
27+
28+
async def connect_async_soup(remote: tuple[str, int], user: str, passwd: str, session_id, # pylint: disable=R0913
29+
session_factory: Callable[[soup.SoupClientSession], Asn1SoupClientSession],
30+
sequence: int = 0,
31+
client_heartbeat_interval: int = 10,
32+
server_heartbeat_interval: int = 10):
33+
"""
34+
Connect to the Soup-Asn1 server.
35+
36+
:param remote: tuple of host and port
37+
:param user: Username to login
38+
:param passwd: Password to login
39+
:param session_id: Name of the session to join [Default=''] .
40+
:param session_factory: Factory to create a ClientSession.
41+
:param sequence: The sequence number. [Default=1]
42+
:param client_heartbeat_interval: seconds between client heartbeats.
43+
:param server_heartbeat_interval: seconds between server heartbeats.
44+
:return: ClientSession
45+
"""
46+
47+
# Create a soup session
48+
soup_session = await soup.connect_async(
49+
remote, user, passwd, session_id, sequence=sequence,
50+
client_heartbeat_interval=client_heartbeat_interval,
51+
server_heartbeat_interval=server_heartbeat_interval
52+
)
53+
54+
# Create an asn1-soup-session (with the soup session created above)
55+
return session_factory(soup_session)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import os
2+
import logging
3+
import shutil
4+
from importlib import resources
5+
from pathlib import Path
6+
7+
import attrs
8+
import chevron
9+
import click
10+
11+
from . import templates
12+
13+
14+
__all__ = [
15+
'generate_soup_app'
16+
]
17+
TEMPLATES_PATH = resources.files(templates)
18+
LOG = logging.getLogger(__name__)
19+
20+
21+
@attrs.define(auto_attribs=True)
22+
class Ans1Generator:
23+
asn1_input_files_dir: str
24+
app_name: str
25+
pdu: str
26+
op_dir: str
27+
template: str
28+
prefix: str = ''
29+
package_name: str = ''
30+
generate_init_file: bool = False
31+
_op_file: str = None
32+
_module_name: str = None
33+
_asn1_files: list = None
34+
35+
def __attrs_post_init__(self):
36+
prefix = f'{self.prefix}_' if self.prefix else ''
37+
self.template = self.template if self.template.endswith('.mustache') else f'{self.template}.mustache'
38+
self.template = os.path.join(str(TEMPLATES_PATH), self.template)
39+
self._module_name = f'{prefix}{self.app_name}'
40+
self._op_file = os.path.join(self.op_dir, f'{self._module_name}.py')
41+
self._asn1_files = [
42+
os.path.join(self.asn1_input_files_dir, file)
43+
for file in os.listdir(self.asn1_input_files_dir)
44+
if file.endswith('.asn1')
45+
]
46+
shutil.rmtree(self.op_dir)
47+
Path(self.op_dir).mkdir(parents=True, exist_ok=True)
48+
49+
def generate(self, extra_context=None):
50+
context = {
51+
'app_name': self.app_name,
52+
'pdu_name': self.pdu,
53+
'package_name': self.package_name,
54+
'spec': {
55+
'name': self.app_name,
56+
'capitalised_name': self.app_name.capitalize()
57+
},
58+
}
59+
context.update(extra_context or {})
60+
61+
generated_files = [Ans1Generator._generate(self.template, context, self._op_file)]
62+
if self.generate_init_file:
63+
generated_files.append(self._generate_init_file())
64+
65+
self._copy_asn1_files()
66+
67+
return generated_files
68+
69+
def _generate_init_file(self):
70+
init_file = os.path.join(self.op_dir, '__init__.py')
71+
context = {
72+
'modules': [
73+
{'name': self._module_name}
74+
]
75+
}
76+
init_template = os.path.join(str(TEMPLATES_PATH), 'init.mustache')
77+
Ans1Generator._generate(init_template, context, init_file)
78+
return init_file
79+
80+
def _copy_asn1_files(self):
81+
op_spec_dir = os.path.join(self.op_dir, 'spec')
82+
Path(op_spec_dir).mkdir(parents=True, exist_ok=True)
83+
for spec_file in self._asn1_files:
84+
shutil.copy2(spec_file, os.path.join(op_spec_dir, os.path.basename(spec_file)))
85+
86+
@staticmethod
87+
def _generate(template, context, op_file):
88+
with open(op_file, 'a', encoding='utf-8') as op, open(template, 'r', encoding='utf-8') as inp:
89+
code_as_string = chevron.render(inp.read(), context, partials_path=str(TEMPLATES_PATH))
90+
op.write(code_as_string)
91+
print(f'Generated: {op_file}')
92+
return op_file
93+
94+
95+
@click.command()
96+
@click.option('--asn1-files-dir', type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True))
97+
@click.option('--app-name', type=click.STRING)
98+
@click.option('--pdu-name', type=click.STRING)
99+
@click.option('--prefix', type=click.STRING, default='')
100+
@click.option('--op-dir', type=click.Path(exists=True, writable=True))
101+
@click.option('--package-name', type=click.STRING)
102+
@click.option('--init-file/--no-init-file', show_default=True, default=True)
103+
def generate_soup_app(asn1_files_dir, app_name, pdu_name, prefix, op_dir, package_name, init_file):
104+
generator = Ans1Generator(
105+
asn1_files_dir,
106+
app_name,
107+
pdu_name,
108+
op_dir,
109+
template='ans1_soup_app.mustache',
110+
prefix=prefix,
111+
package_name=package_name,
112+
generate_init_file=init_file
113+
)
114+
generator.generate()
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import importlib
2+
import logging
3+
from typing import ClassVar
4+
5+
import asn1tools
6+
import attrs
7+
8+
from asn1tools import CompileError, DecodeError, ParseError
9+
from nasdaq_protocols.common import Serializable, DuplicateMessageException
10+
11+
__all__ = [
12+
'Asn1Spec',
13+
'Asn1Message'
14+
]
15+
LOG = logging.getLogger(__name__)
16+
17+
18+
class Asn1Spec:
19+
SpecMap = {}
20+
21+
SpecName: str
22+
SpecPkgDir: str
23+
Spec: any
24+
25+
def __init_subclass__(cls, **kwargs):
26+
try:
27+
cls.SpecName, cls.SpecPkgDir = kwargs['spec_name'], kwargs['spec_pkg_dir']
28+
except KeyError:
29+
raise AttributeError('Missing spec_name or spec_pkg_dir')
30+
31+
if cls.SpecName in Asn1Spec.SpecMap:
32+
spec_name = cls.SpecName
33+
LOG.error(f'asn1 spec {spec_name} exists, existing spec = {Asn1Spec.SpecMap[spec_name]}')
34+
raise DuplicateMessageException(existing_msg=Asn1Spec.SpecMap[spec_name], new_msg=cls)
35+
36+
try:
37+
cls.Spec = Asn1Spec._compile(cls.SpecPkgDir)
38+
Asn1Spec.SpecMap[cls.SpecName] = cls
39+
except (CompileError, ParseError) as error:
40+
LOG.error(f'Compile error - {cls.SpecName}, {error=}')
41+
raise error
42+
43+
@classmethod
44+
def decode(cls, pdu_name: str, bytes_: bytes) -> tuple[int, dict[any, any]]:
45+
try:
46+
decoded, length = cls.Spec.decode_with_length(pdu_name, bytes_)
47+
return length, decoded
48+
except DecodeError as err:
49+
LOG.error(f'error while decoding asn1- {err=}')
50+
return 0, {}
51+
52+
@staticmethod
53+
def _compile(spec_pkg_dir):
54+
files = list(
55+
filter(
56+
lambda x: x.suffix == '.asn1',
57+
importlib.resources.files(spec_pkg_dir).iterdir()
58+
)
59+
)
60+
return asn1tools.compiler.compile_files(files)
61+
62+
63+
@attrs.define
64+
class Asn1Message(Serializable):
65+
PduName: ClassVar[str]
66+
Spec: ClassVar[Asn1Spec]
67+
68+
def __init_subclass__(cls, **kwargs):
69+
if all(_ in kwargs for _ in ['spec', 'pdu_name']):
70+
cls.PduName = kwargs['pdu_name']
71+
cls.Spec = kwargs['spec']
72+
LOG.info(f'subclassed Asn1Message, {cls.Spec=}, {cls.PduName=}')
73+
74+
def to_bytes(self):
75+
raise NotImplementedError('Encoding asn1 message is not supported yet.')
76+
77+
@classmethod
78+
def from_bytes(cls, bytes_: bytes):
79+
return cls.Spec.decode(cls.PduName, bytes_)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import asyncio
2+
from typing import Callable, Type, Awaitable, ClassVar
3+
4+
import attrs
5+
from nasdaq_protocols.common import DispatchableMessageQueue, logable
6+
from nasdaq_protocols import soup
7+
8+
from .core import Asn1Message
9+
10+
11+
__all__ = [
12+
'OnAsn1MessageCoro',
13+
'OnAns1CloseCoro',
14+
'Ans1SoupSessionId',
15+
'Asn1SoupClientSession'
16+
]
17+
OnAsn1MessageCoro = Callable[[Type[Asn1Message]], Awaitable[None]]
18+
OnAns1CloseCoro = Callable[[], Awaitable[None]]
19+
20+
21+
@attrs.define(auto_attribs=True)
22+
class Ans1SoupSessionId:
23+
soup_session_id: soup.SoupSessionId = None
24+
25+
def __str__(self):
26+
if self.soup_session_id:
27+
return f'asn1-{self.soup_session_id}'
28+
return 'asn1-nosoup'
29+
30+
31+
@attrs.define(auto_attribs=True)
32+
@logable
33+
class Asn1SoupClientSession:
34+
""" Soup client session that can read messages from soup server.
35+
36+
Currently, only one pdu is supported.
37+
"""
38+
Asn1Message: ClassVar[Asn1Message]
39+
soup_session: soup.SoupClientSession
40+
on_msg_coro: OnAsn1MessageCoro = None
41+
on_close_coro: OnAns1CloseCoro = None
42+
closed: bool = False
43+
_session_id: Ans1SoupSessionId = None
44+
_close_event: asyncio.Event = None
45+
_message_queue: DispatchableMessageQueue = None
46+
47+
def __init_subclass__(cls, **kwargs):
48+
if 'asn1_message' not in kwargs:
49+
raise AttributeError("Missing 'asn1_message' attribute")
50+
cls.Asn1Message = kwargs['asn1_message']
51+
52+
def __attrs_post_init__(self):
53+
self._session_id = Ans1SoupSessionId(self.soup_session.session_id)
54+
self._message_queue = DispatchableMessageQueue(self._session_id, self.on_msg_coro)
55+
self.soup_session.set_handlers(on_msg_coro=self._on_soup_message, on_close_coro=self._on_soup_close)
56+
self.soup_session.start_dispatching()
57+
58+
async def receive_message(self):
59+
"""
60+
Asynchronously receive a message from the soup session.
61+
62+
This method blocks until a message is received by the session.
63+
"""
64+
return await self._message_queue.get()
65+
66+
async def close(self):
67+
"""
68+
Asynchronously close the session.
69+
"""
70+
if self._close_event or self.closed:
71+
self.log.debug('%s> closing in progress..', self._session_id)
72+
return
73+
self._close_event = asyncio.Event()
74+
self.soup_session.initiate_close()
75+
await self._close_event.wait()
76+
self.log.debug('%s> closed.', self._session_id)
77+
78+
async def _on_soup_message(self, message: soup.SoupMessage):
79+
if isinstance(message, soup.SequencedData):
80+
self.log.debug('%s> incoming sequenced bytes_', self._session_id)
81+
await self._message_queue.put(
82+
self.decode(message.data)[1]
83+
)
84+
85+
async def _on_soup_close(self):
86+
await self._message_queue.stop()
87+
if self.on_close_coro is not None:
88+
await self.on_close_coro()
89+
if self._close_event:
90+
self._close_event.set()
91+
self.closed = True
92+
93+
@classmethod
94+
def decode(cls, bytes_: bytes):
95+
"""
96+
Decode the given bytes into an asn1 message.
97+
"""
98+
return cls.Asn1Message.from_bytes(bytes_)

src/nasdaq_protocols/asn1_app/templates/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)