Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

MLLP client implementation & mllp_send command line tool.

  • Loading branch information...
commit 8ecc3514f56bbcdab88535a3cb494e82b8719e18 1 parent b47ba7f
John Paulett authored
1  .gitignore
@@ -2,6 +2,7 @@
2 2 *#*
3 3 *.pyc
4 4 *.egg-info/
  5 +*.egg
5 6 /build/
6 7 /dist/
7 8 /env/
8 docs/api.rst
Source Rendered
@@ -25,3 +25,11 @@ Data Types
25 25 .. autoclass:: hl7.Segment
26 26
27 27 .. autoclass:: hl7.Field
  28 +
  29 +
  30 +MLLP Network Client
  31 +-------------------
  32 +
  33 +.. autoclass:: hl7.client.MLLPClient
  34 + :members: send_message, send, close
  35 +
3  docs/changelog.rst
Source Rendered
@@ -4,6 +4,9 @@ Change Log
4 4 0.2.1 - unreleased
5 5 ------------------
6 6
  7 +* Added MLLP client (:py:class:`hl7.client.MLLPClient`) and command line tool,
  8 + :ref:`mllp_send <mllp-send>`.
  9 +
7 10 0.2.0 - 2011-06-12
8 11 ------------------
9 12
28 docs/index.rst
Source Rendered
... ... @@ -1,8 +1,10 @@
1 1 python-hl7 - Easy HL7 v2.x Parsing
2 2 ==================================
3 3
4   -python-hl7 is a simple library for parsing messages of Health Level 7
5   -(HL7) version 2.x into Python objects.
  4 +python-hl7 is a simple library for parsing messages of Health Level 7
  5 +(HL7) version 2.x into Python objects. python-hl7 includes a simple
  6 +client that can send HL7 messages to a Minimal Lower Level Protocol (MLLP)
  7 +server (:ref:`mllp_send <mllp-send>`).
6 8
7 9 HL7 is a communication protocol and message format for
8 10 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
123 125 >>> h.segment('PID')[3][0]
124 126 u'555-44-4444'
125 127
  128 +.. _mllp-send:
  129 +
  130 +MLLP network client - ``mllp_send``
  131 +-----------------------------------
  132 +
  133 +python-hl7 features a simple network client, ``mllp_send``, which reads HL7
  134 +messages from a file or ``sys.stdin`` and posts them to an MLLP server.
  135 +``mllp_send`` is a command-line wrapper around
  136 +:py:class:`hl7.client.MLLPClient`.
  137 +
  138 +::
  139 +
  140 + Usage: mllp_send [options] <server>
  141 +
  142 + Options:
  143 + -h, --help show this help message and exit
  144 + -p PORT, --port=PORT port to connect to
  145 + -f FILE, --file=FILE read from FILE instead of stdin
  146 + -q, --quiet do not print status messages to stdout
  147 +
  148 +
126 149 Contents
127 150 --------
128 151
@@ -161,3 +184,4 @@ HL7 References:
161 184 * `nule.org's Introduction to HL7 <http://nule.org/wp/?page_id=99>`_
162 185 * `hl7.org <http://www.hl7.org/>`_
163 186 * `OpenMRS's HL7 documentation <http://openmrs.org/wiki/HL7>`_
  187 +* `Transport Specification: MLLP <http://www.hl7.org/v3ballot/html/infrastructure/transport/transport-mllp.html>`_
135 hl7/client.py
... ... @@ -0,0 +1,135 @@
  1 +from optparse import OptionParser
  2 +
  3 +import os.path
  4 +import socket
  5 +import sys
  6 +
  7 +
  8 +SB = '\x0b' #<SB>, vertical tab
  9 +EB = '\x1c' #<EB>, file separator
  10 +CR = '\x0d' #<CR>, \r
  11 +
  12 +FF = '\x0c' # <FF>, new page form feed
  13 +
  14 +RECV_BUFFER = 4096
  15 +
  16 +class MLLPException(Exception): pass
  17 +
  18 +class MLLPClient(object):
  19 + """
  20 + A basic, blocking, HL7 MLLP client based upon :py:mod:`socket`.
  21 +
  22 + MLLPClient implements two methods for sending data to the server.
  23 +
  24 + * :py:meth:`MLLPClient.send` for raw data that already is wrapped in the
  25 + appropriate MLLP container (e.g. *<SB>message<EB><CR>*).
  26 + * :py:meth:`MLLPClient.send_message` will wrap the message in the MLLP
  27 + container
  28 +
  29 + Can be used by the ``with`` statement to ensure :py:meth:`MLLPClient.close`
  30 + is called::
  31 +
  32 + with MLLPClient(host, port) as client:
  33 + client.send_message('MSH|...')
  34 +
  35 + """
  36 + def __init__(self, host, port):
  37 + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  38 + self.socket.connect((host, port))
  39 +
  40 + def __enter__(self):
  41 + return self
  42 +
  43 + def __exit__(self, exc_type, exc_val, trackeback):
  44 + self.close()
  45 +
  46 + def close(self):
  47 + """Release the socket connection"""
  48 + self.socket.close()
  49 +
  50 + def send_message(self, message):
  51 + """Wraps a str, unicode, or :py:cls:`hl7.Message` in a MLLP container
  52 + and send the message to the server
  53 + """
  54 + # wrap in MLLP message container
  55 + data = SB + unicode(message) + EB + CR
  56 + # TODO consider encoding (e.g. UTF-8)
  57 + return self.send(data)
  58 +
  59 + def send(self, data):
  60 + """Low-level, direct access to the socket.send (data must be already
  61 + wrapped in an MLLP container). Blocks until the server returns.
  62 + """
  63 + # upload the data
  64 + self.socket.send(data)
  65 + # wait for the ACK/NACK
  66 + return self.socket.recv(RECV_BUFFER)
  67 +
  68 +
  69 +# wrappers to make testing easier
  70 +def stdout(content):
  71 + sys.stdout.write(content + '\n')
  72 +
  73 +def stdin():
  74 + return sys.stdin
  75 +
  76 +
  77 +def read_stream(stream):
  78 + """Buffer the stream and yield individual, stripped messages"""
  79 + _buffer = ''
  80 +
  81 + while True:
  82 + data = stream.read(RECV_BUFFER)
  83 + if data == '':
  84 + break
  85 + # usually should be broken up by EB, but I have seen FF separating
  86 + # messages
  87 + messages = (_buffer + data).split(EB if FF not in data else FF)
  88 +
  89 + # whatever is in the last chunk is an uncompleted message, so put back
  90 + # into the buffer
  91 + _buffer = messages.pop(-1)
  92 +
  93 + for m in messages:
  94 + yield m.strip(SB+CR)
  95 +
  96 + if len(_buffer.strip()) > 0:
  97 + raise MLLPException('buffer not terminated: %s' % _buffer)
  98 +
  99 +def mllp_send():
  100 + """Command line tool to send messages to an MLLP server"""
  101 + # set up the command line options
  102 + script_name = os.path.basename(sys.argv[0])
  103 + parser = OptionParser(usage=script_name + ' [options] <server>')
  104 + parser.add_option('-p', '--port',
  105 + action='store', type='int', dest='port', default=6661,
  106 + help='port to connect to')
  107 + parser.add_option('-f', '--file', dest='filename',
  108 + help='read from FILE instead of stdin', metavar='FILE')
  109 + parser.add_option('-q', '--quiet',
  110 + action='store_true', dest='verbose', default=True,
  111 + help='do not print status messages to stdout')
  112 +
  113 + (options, args) = parser.parse_args()
  114 + if len(args) == 1:
  115 + host = args[0]
  116 + else:
  117 + # server not present
  118 + parser.print_usage()
  119 + sys.stderr.write('server required\n')
  120 + return
  121 +
  122 + if options.filename is not None:
  123 + stream = open(options.filename, 'rb') #FIXME with_statement
  124 + else:
  125 + stream = stdin()
  126 +
  127 + with MLLPClient(host, options.port) as client:
  128 + for message in read_stream(stream):
  129 + result = client.send_message(message)
  130 + if options.verbose:
  131 + stdout(result)
  132 +
  133 +
  134 +if __name__ == '__main__':
  135 + mllp_send()
6 setup.py
@@ -30,5 +30,11 @@
30 30 ],
31 31 packages = ['hl7'],
32 32 test_suite = 'tests',
  33 + tests_require = ['mock'],
  34 + entry_points = {
  35 + 'console_scripts': [
  36 + 'mllp_send = hl7.client:mllp_send',
  37 + ],
  38 + },
33 39 zip_safe=True,
34 40 )
168 tests/test_client.py
... ... @@ -0,0 +1,168 @@
  1 +from hl7.client import MLLPClient, MLLPException, mllp_send, CR, SB, EB
  2 +from mock import patch, Mock
  3 +from optparse import Values
  4 +from shutil import rmtree
  5 +from tempfile import mkdtemp
  6 +
  7 +import os
  8 +import socket
  9 +import unittest
  10 +
  11 +
  12 +class MLLPClientTest(unittest.TestCase):
  13 + def setUp(self):
  14 + # use a mock version of socket
  15 + self.socket_patch = patch('hl7.client.socket.socket')
  16 + self.mock_socket = self.socket_patch.start()
  17 +
  18 + self.client = MLLPClient('localhost', 6666)
  19 +
  20 + def tearDown(self):
  21 + # unpatch socket
  22 + self.socket_patch.stop()
  23 +
  24 + def test_connect(self):
  25 + self.mock_socket.assert_called_once_with(socket.AF_INET,
  26 + socket.SOCK_STREAM)
  27 + self.client.socket.connect.assert_called_once_with(('localhost', 6666))
  28 +
  29 + def test_close(self):
  30 + self.client.close()
  31 + self.client.socket.close.assert_called_once_with()
  32 +
  33 + def test_send(self):
  34 + self.client.socket.recv.return_value = 'thanks'
  35 +
  36 + result = self.client.send('foobar\n')
  37 + self.assertEqual(result, 'thanks')
  38 +
  39 + self.client.socket.send.assert_called_once_with('foobar\n')
  40 + self.client.socket.recv.assert_called_once_with(4096)
  41 +
  42 + def test_send_message(self):
  43 + self.client.socket.recv.return_value = 'thanks'
  44 +
  45 + result = self.client.send_message('foobar')
  46 + self.assertEqual(result, 'thanks')
  47 +
  48 + self.client.socket.send.assert_called_once_with('\x0bfoobar\x1c\x0d')
  49 +
  50 + def test_context_manager(self):
  51 + with MLLPClient('localhost', 6666) as client:
  52 + client.send('hello world')
  53 +
  54 + self.client.socket.send.assert_called_once_with('hello world')
  55 + self.client.socket.close.assert_called_once_with()
  56 +
  57 + def test_context_manager_exception(self):
  58 + try:
  59 + with MLLPClient('localhost', 6666):
  60 + raise Exception()
  61 + self.fail()
  62 + except:
  63 + # expected
  64 + pass
  65 +
  66 + # socket.close should be called via the with statement
  67 + self.client.socket.close.assert_called_once_with()
  68 +
  69 +class MLLPSendTest(unittest.TestCase):
  70 + def setUp(self):
  71 + # patch to avoid touching sys and socket
  72 + self.socket_patch = patch('hl7.client.socket.socket')
  73 + self.mock_socket = self.socket_patch.start()
  74 + self.mock_socket().recv.return_value = 'thanks'
  75 +
  76 + self.stdout_patch = patch('hl7.client.stdout')
  77 + self.mock_stdout = self.stdout_patch.start()
  78 +
  79 + self.stdin_patch = patch('hl7.client.stdin')
  80 + self.mock_stdin = self.stdin_patch.start()
  81 +
  82 + # we need a temporary directory
  83 + self.dir = mkdtemp()
  84 + self.write(SB + 'foobar' + EB + CR)
  85 +
  86 + self.option_values = Values({
  87 + 'port': 6661,
  88 + 'filename': os.path.join(self.dir, 'test.hl7'),
  89 + 'verbose': True,
  90 + })
  91 +
  92 + self.options_patch = patch('hl7.client.OptionParser')
  93 + option_parser = self.options_patch.start()
  94 + self.mock_options = Mock()
  95 + option_parser.return_value = self.mock_options
  96 + self.mock_options.parse_args.return_value = (self.option_values,
  97 + ['localhost'])
  98 +
  99 + def tearDown(self):
  100 + # unpatch
  101 + self.socket_patch.stop()
  102 + self.options_patch.stop()
  103 + self.stdout_patch.stop()
  104 + self.stdin_patch.stop()
  105 +
  106 + # clean up the temp directory
  107 + rmtree(self.dir)
  108 +
  109 + def write(self, content, path='test.hl7'):
  110 + with open(os.path.join(self.dir, path), 'w') as f:
  111 + f.write(content)
  112 +
  113 + def test_send(self):
  114 + mllp_send()
  115 +
  116 + self.mock_socket().connect.assert_called_once_with(('localhost', 6661))
  117 + self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
  118 + self.mock_stdout.assert_called_once_with('thanks')
  119 +
  120 + def test_send_mutliple(self):
  121 + self.mock_socket().recv.return_value = 'thanks'
  122 + self.write(SB + 'foobar' + EB + CR + SB + 'hello' + EB + CR)
  123 +
  124 + mllp_send()
  125 +
  126 + self.assertEqual(self.mock_socket().send.call_args_list[0][0][0],
  127 + SB + 'foobar' + EB + CR)
  128 + self.assertEqual(self.mock_socket().send.call_args_list[1][0][0],
  129 + SB + 'hello' + EB + CR)
  130 +
  131 + def test_leftover_buffer(self):
  132 + self.write(SB + 'foobar' + EB + CR + SB + 'stuff')
  133 +
  134 + self.assertRaises(MLLPException, mllp_send)
  135 +
  136 + self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
  137 +
  138 + def test_quiet(self):
  139 + self.option_values.verbose = False
  140 +
  141 + mllp_send()
  142 +
  143 + self.mock_socket().send.assert_called_once_with(SB + 'foobar' + EB + CR)
  144 + self.assertFalse(self.mock_stdout.called)
  145 +
  146 + def test_port(self):
  147 + self.option_values.port = 7890
  148 +
  149 + mllp_send()
  150 +
  151 + self.mock_socket().connect.assert_called_once_with(('localhost', 7890))
  152 +
  153 + def test_stdin(self):
  154 + class FakeStream(object):
  155 + count = 0
  156 + def read(self, buf):
  157 + self.count += 1
  158 + if self.count == 1:
  159 + return SB + 'hello' + EB + CR
  160 + else:
  161 + return ''
  162 +
  163 + self.option_values.filename = None
  164 + self.mock_stdin.return_value = FakeStream()
  165 +
  166 + mllp_send()
  167 +
  168 + self.mock_socket().send.assert_called_once_with(SB + 'hello' + EB + CR)

0 comments on commit 8ecc351

Please sign in to comment.
Something went wrong with that request. Please try again.