Permalink
Browse files

MLLP client implementation & mllp_send command line tool.

  • Loading branch information...
1 parent b47ba7f commit 8ecc3514f56bbcdab88535a3cb494e82b8719e18 @johnpaulett committed Aug 31, 2011
Showing with 347 additions and 2 deletions.
  1. +1 −0 .gitignore
  2. +8 −0 docs/api.rst
  3. +3 −0 docs/changelog.rst
  4. +26 −2 docs/index.rst
  5. +135 −0 hl7/client.py
  6. +6 −0 setup.py
  7. +168 −0 tests/test_client.py
View
@@ -2,6 +2,7 @@
*#*
*.pyc
*.egg-info/
+*.egg
/build/
/dist/
/env/
View
@@ -25,3 +25,11 @@ Data Types
.. autoclass:: hl7.Segment
.. autoclass:: hl7.Field
+
+
+MLLP Network Client
+-------------------
+
+.. autoclass:: hl7.client.MLLPClient
+ :members: send_message, send, close
+
View
@@ -4,6 +4,9 @@ Change Log
0.2.1 - unreleased
------------------
+* Added MLLP client (:py:class:`hl7.client.MLLPClient`) and command line tool,
+ :ref:`mllp_send <mllp-send>`.
+
0.2.0 - 2011-06-12
------------------
View
@@ -1,8 +1,10 @@
python-hl7 - Easy HL7 v2.x Parsing
==================================
-python-hl7 is a simple library for parsing messages of Health Level 7
-(HL7) version 2.x into Python objects.
+python-hl7 is a simple library for parsing messages of Health Level 7
+(HL7) version 2.x into Python objects. python-hl7 includes a simple
+client that can send HL7 messages to a Minimal Lower Level Protocol (MLLP)
+server (:ref:`mllp_send <mllp-send>`).
HL7 is a communication protocol and message format for
health care data. It is the de-facto standard for transmitting data
@@ -123,6 +125,27 @@ wrapper around :py:meth:`hl7.Message.segments` that returns the first matching
>>> h.segment('PID')[3][0]
u'555-44-4444'
+.. _mllp-send:
+
+MLLP network client - ``mllp_send``
+-----------------------------------
+
+python-hl7 features a simple network client, ``mllp_send``, which reads HL7
+messages from a file or ``sys.stdin`` and posts them to an MLLP server.
+``mllp_send`` is a command-line wrapper around
+:py:class:`hl7.client.MLLPClient`.
+
+::
+
+ Usage: mllp_send [options] <server>
+
+ Options:
+ -h, --help show this help message and exit
+ -p PORT, --port=PORT port to connect to
+ -f FILE, --file=FILE read from FILE instead of stdin
+ -q, --quiet do not print status messages to stdout
+
+
Contents
--------
@@ -161,3 +184,4 @@ HL7 References:
* `nule.org's Introduction to HL7 <http://nule.org/wp/?page_id=99>`_
* `hl7.org <http://www.hl7.org/>`_
* `OpenMRS's HL7 documentation <http://openmrs.org/wiki/HL7>`_
+* `Transport Specification: MLLP <http://www.hl7.org/v3ballot/html/infrastructure/transport/transport-mllp.html>`_
View
@@ -0,0 +1,135 @@
+from optparse import OptionParser
+
+import os.path
+import socket
+import sys
+
+
+SB = '\x0b' #<SB>, vertical tab
+EB = '\x1c' #<EB>, file separator
+CR = '\x0d' #<CR>, \r
+
+FF = '\x0c' # <FF>, new page form feed
+
+RECV_BUFFER = 4096
+
+class MLLPException(Exception): pass
+
+class MLLPClient(object):
+ """
+ A basic, blocking, HL7 MLLP client based upon :py:mod:`socket`.
+
+ MLLPClient implements two methods for sending data to the server.
+
+ * :py:meth:`MLLPClient.send` for raw data that already is wrapped in the
+ appropriate MLLP container (e.g. *<SB>message<EB><CR>*).
+ * :py:meth:`MLLPClient.send_message` will wrap the message in the MLLP
+ container
+
+ Can be used by the ``with`` statement to ensure :py:meth:`MLLPClient.close`
+ is called::
+
+ with MLLPClient(host, port) as client:
+ client.send_message('MSH|...')
+
+ """
+ def __init__(self, host, port):
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.socket.connect((host, port))
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, trackeback):
+ self.close()
+
+ def close(self):
+ """Release the socket connection"""
+ self.socket.close()
+
+ def send_message(self, message):
+ """Wraps a str, unicode, or :py:cls:`hl7.Message` in a MLLP container
+ and send the message to the server
+ """
+ # wrap in MLLP message container
+ data = SB + unicode(message) + EB + CR
+ # TODO consider encoding (e.g. UTF-8)
+ return self.send(data)
+
+ def send(self, data):
+ """Low-level, direct access to the socket.send (data must be already
+ wrapped in an MLLP container). Blocks until the server returns.
+ """
+ # upload the data
+ self.socket.send(data)
+ # wait for the ACK/NACK
+ return self.socket.recv(RECV_BUFFER)
+
+
+# wrappers to make testing easier
+def stdout(content):
+ sys.stdout.write(content + '\n')
+
+def stdin():
+ return sys.stdin
+
+
+def read_stream(stream):
+ """Buffer the stream and yield individual, stripped messages"""
+ _buffer = ''
+
+ while True:
+ data = stream.read(RECV_BUFFER)
+ if data == '':
+ break
+ # usually should be broken up by EB, but I have seen FF separating
+ # messages
+ messages = (_buffer + data).split(EB if FF not in data else FF)
+
+ # whatever is in the last chunk is an uncompleted message, so put back
+ # into the buffer
+ _buffer = messages.pop(-1)
+
+ for m in messages:
+ yield m.strip(SB+CR)
+
+ if len(_buffer.strip()) > 0:
+ raise MLLPException('buffer not terminated: %s' % _buffer)
+
+def mllp_send():
+ """Command line tool to send messages to an MLLP server"""
+ # set up the command line options
+ script_name = os.path.basename(sys.argv[0])
+ parser = OptionParser(usage=script_name + ' [options] <server>')
+ parser.add_option('-p', '--port',
+ action='store', type='int', dest='port', default=6661,
+ help='port to connect to')
+ parser.add_option('-f', '--file', dest='filename',
+ help='read from FILE instead of stdin', metavar='FILE')
+ parser.add_option('-q', '--quiet',
+ action='store_true', dest='verbose', default=True,
+ help='do not print status messages to stdout')
+
+ (options, args) = parser.parse_args()
+ if len(args) == 1:
+ host = args[0]
+ else:
+ # server not present
+ parser.print_usage()
+ sys.stderr.write('server required\n')
+ return
+
+ if options.filename is not None:
+ stream = open(options.filename, 'rb') #FIXME with_statement
+ else:
+ stream = stdin()
+
+ with MLLPClient(host, options.port) as client:
+ for message in read_stream(stream):
+ result = client.send_message(message)
+ if options.verbose:
+ stdout(result)
+
+
+if __name__ == '__main__':
+ mllp_send()
View
@@ -30,5 +30,11 @@
],
packages = ['hl7'],
test_suite = 'tests',
+ tests_require = ['mock'],
+ entry_points = {
+ 'console_scripts': [
+ 'mllp_send = hl7.client:mllp_send',
+ ],
+ },
zip_safe=True,
)
View
@@ -0,0 +1,168 @@
+from hl7.client import MLLPClient, MLLPException, mllp_send, CR, SB, EB
+from mock import patch, Mock
+from optparse import Values
+from shutil import rmtree
+from tempfile import mkdtemp
+
+import os
+import socket
+import unittest
+
+
+class MLLPClientTest(unittest.TestCase):
+ def setUp(self):
+ # use a mock version of socket
+ self.socket_patch = patch('hl7.client.socket.socket')
+ self.mock_socket = self.socket_patch.start()
+
+ self.client = MLLPClient('localhost', 6666)
+
+ def tearDown(self):
+ # unpatch socket
+ self.socket_patch.stop()
+
+ def test_connect(self):
+ self.mock_socket.assert_called_once_with(socket.AF_INET,
+ socket.SOCK_STREAM)
+ self.client.socket.connect.assert_called_once_with(('localhost', 6666))
+
+ def test_close(self):
+ self.client.close()
+ self.client.socket.close.assert_called_once_with()
+
+ def test_send(self):
+ self.client.socket.recv.return_value = 'thanks'
+
+ result = self.client.send('foobar\n')
+ self.assertEqual(result, 'thanks')
+
+ self.client.socket.send.assert_called_once_with('foobar\n')
+ self.client.socket.recv.assert_called_once_with(4096)
+
+ def test_send_message(self):
+ self.client.socket.recv.return_value = 'thanks'
+
+ result = self.client.send_message('foobar')
+ self.assertEqual(result, 'thanks')
+
+ self.client.socket.send.assert_called_once_with('\x0bfoobar\x1c\x0d')
+
+ def test_context_manager(self):
+ with MLLPClient('localhost', 6666) as client:
+ client.send('hello world')
+
+ self.client.socket.send.assert_called_once_with('hello world')
+ self.client.socket.close.assert_called_once_with()
+
+ def test_context_manager_exception(self):
+ try:
+ with MLLPClient('localhost', 6666):
+ raise Exception()
+ self.fail()
+ except:
+ # expected
+ pass
+
+ # socket.close should be called via the with statement
+ self.client.socket.close.assert_called_once_with()
+
+class MLLPSendTest(unittest.TestCase):
+ def setUp(self):
+ # patch to avoid touching sys and socket
+ self.socket_patch = patch('hl7.client.socket.socket')
+ self.mock_socket = self.socket_patch.start()
+ self.mock_socket().recv.return_value = 'thanks'
+
+ self.stdout_patch = patch('hl7.client.stdout')
+ self.mock_stdout = self.stdout_patch.start()
+
+ self.stdin_patch = patch('hl7.client.stdin')
+ self.mock_stdin = self.stdin_patch.start()
+
+ # we need a temporary directory
+ self.dir = mkdtemp()
+ self.write(SB + 'foobar' + EB + CR)
+
+ self.option_values = Values({
+ 'port': 6661,
+ 'filename': os.path.join(self.dir, 'test.hl7'),
+ 'verbose': True,
+ })
+
+ self.options_patch = patch('hl7.client.OptionParser')
+ option_parser = self.options_patch.start()
+ self.mock_options = Mock()
+ option_parser.return_value = self.mock_options
+ self.mock_options.parse_args.return_value = (self.option_values,
+ ['localhost'])
+
+ def tearDown(self):
+ # unpatch
+ self.socket_patch.stop()
+ self.options_patch.stop()
+ self.stdout_patch.stop()
+ self.stdin_patch.stop()
+
+ # clean up the temp directory
+ rmtree(self.dir)
+
+ def write(self, content, path='test.hl7'):
+ with open(os.path.join(self.dir, path), 'w') as f:
+ f.write(content)
+
+ def test_send(self):
+ mllp_send()
+
+ self.mock_socket().connect.assert_called_once_with(('localhost', 6661))
+ self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
+ self.mock_stdout.assert_called_once_with('thanks')
+
+ def test_send_mutliple(self):
+ self.mock_socket().recv.return_value = 'thanks'
+ self.write(SB + 'foobar' + EB + CR + SB + 'hello' + EB + CR)
+
+ mllp_send()
+
+ self.assertEqual(self.mock_socket().send.call_args_list[0][0][0],
+ SB + 'foobar' + EB + CR)
+ self.assertEqual(self.mock_socket().send.call_args_list[1][0][0],
+ SB + 'hello' + EB + CR)
+
+ def test_leftover_buffer(self):
+ self.write(SB + 'foobar' + EB + CR + SB + 'stuff')
+
+ self.assertRaises(MLLPException, mllp_send)
+
+ self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
+
+ def test_quiet(self):
+ self.option_values.verbose = False
+
+ mllp_send()
+
+ self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
+ self.assertFalse(self.mock_stdout.called)
+
+ def test_port(self):
+ self.option_values.port = 7890
+
+ mllp_send()
+
+ self.mock_socket().connect.assert_called_once_with(('localhost', 7890))
+
+ def test_stdin(self):
+ class FakeStream(object):
+ count = 0
+ def read(self, buf):
+ self.count += 1
+ if self.count == 1:
+ return SB + 'hello' + EB + CR
+ else:
+ return ''
+
+ self.option_values.filename = None
+ self.mock_stdin.return_value = FakeStream()
+
+ mllp_send()
+
+ self.mock_socket().send.assert_called_once_with(SB + 'hello' + EB + CR)

0 comments on commit 8ecc351

Please sign in to comment.