├── .coveragerc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── docs.yml │ ├── lint.yml │ └── test.yaml ├── .gitignore ├── .hgignore ├── AUTHORS ├── Agents.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── .gitignore ├── Makefile ├── _static │ └── .gitignore ├── _templates │ └── .gitignore ├── accessors.rst ├── api.rst ├── authors.rst ├── changelog.rst ├── conf.py ├── contribute.rst ├── index.rst ├── license.rst ├── make.bat ├── mllp.rst └── mllp_send.rst ├── hl7 ├── __init__.py ├── accessor.py ├── client.py ├── containers.py ├── datatypes.py ├── exceptions.py ├── mllp │ ├── __init__.py │ ├── exceptions.py │ └── streams.py ├── parser.py └── util.py ├── pyproject.toml ├── tests ├── __init__.py ├── samples.py ├── test_accessor.py ├── test_client.py ├── test_construction.py ├── test_containers.py ├── test_datetime.py ├── test_mllp.py ├── test_parse.py └── test_util.py ├── tox.ini └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | env/* -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: '0 16 * * 1' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: sphinxdocs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.12" 18 | 19 | - name: Docs and Doctests 20 | # Only check docs on single version 21 | run: | 22 | make docs 23 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | 20 | - name: Check code formatting 21 | run: | 22 | make lint -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | env: 9 | primary-python-version: "3.12" 10 | 11 | jobs: 12 | test: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | make init 28 | # - name: Lint with flake8 29 | # run: | 30 | # pip install flake8 31 | # # stop the build if there are Python syntax errors or undefined names 32 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 33 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 34 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 35 | 36 | - name: Test 37 | run: | 38 | make coverage 39 | 40 | - name: Upload to codecov.io 41 | # Only upload coverage report for single version 42 | if: matrix.python-version == env.primary-python-version 43 | uses: codecov/codecov-action@v4 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *\~ 2 | *#* 3 | *.pyc 4 | *.egg-info/ 5 | *.egg 6 | *.mypy_cache/ 7 | .coverage 8 | coverage.xml 9 | /.tox/ 10 | /build/ 11 | /dist/ 12 | /.eggs/ 13 | .vscode 14 | /.venv/ 15 | 16 | # Ignore old venv location 17 | /env/ -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | *.pyc 3 | *.egg-info 4 | *\#* 5 | *~* 6 | build/ 7 | dist/ 8 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | * `John Paulett `_ (john -at- paulett.org) 2 | * `Andrew Wason `_ 3 | * `Kevin Gill `_ 4 | * `Emilien Klein `_ 5 | -------------------------------------------------------------------------------- /Agents.md: -------------------------------------------------------------------------------- 1 | 2 | # HL7 library for Python 3 | 4 | ## Project Overview 5 | 6 | This is a standalone library for parsing HL7 v2.x messages, supporting the maintained versions of Python. 7 | 8 | ## Tech Stack 9 | 10 | - **Testing**: Python unittest 11 | - **Package Manager**: uv 12 | 13 | 14 | ## Project Structure 15 | 16 | ``` 17 | hl7/ 18 | tests/ 19 | docs/ 20 | ``` 21 | 22 | ## Development Guidelines 23 | 24 | ### Key Principles 25 | 26 | ## Environment Setup 27 | 28 | ### Installation Steps 29 | 30 | ```bash 31 | # Run uv sync 32 | make init 33 | ``` 34 | 35 | ## Security Considerations 36 | 37 | - Avoid introducing security issues. 38 | 39 | ## Testing Strategy 40 | 41 | We aim for a high level of test coverage, primarily through unit tests. 42 | 43 | - Occasionally we will use unittest.mock for things that may be otherwise hard to. When patching, prefer to use `autospec=True` 44 | - Whenever you fix a bug, try to add a test case that documents the broken behavior is no longer happening. 45 | - Additionally, we run our sphinx documentation through doctest, not to increase coverage, but to ensure the documentation accurately matches. 46 | 47 | To run the test suite (will run tox for all supported Python versions as well as the doctests): 48 | 49 | ```bash 50 | make tests 51 | ``` 52 | 53 | ## Programmatic Checks for OpenAI Codex 54 | 55 | To run ruff linting: 56 | 57 | ```bash 58 | make lint 59 | ``` 60 | 61 | ## Reference Resources 62 | 63 | - [HL7 Definitions](https://hl7-definition.caristix.com/v2/) 64 | 65 | ## Changelog 66 | 67 | 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2009-2020 John Paulett (john -at- paulett.org) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | 3. The name of the author may not be used to endorse or promote 15 | products derived from this software without specific prior 16 | written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS 19 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE 24 | GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 26 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 27 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 28 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst 2 | include *.py 3 | include tests/*.py 4 | include docs/** 5 | exclude docs/_build 6 | global-exclude *~ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init test tests build docs lint upload bump 2 | 3 | BIN = .venv/bin 4 | PYTHON = $(BIN)/python 5 | UV = $(BIN)/uv 6 | 7 | SPHINXBUILD = $(shell pwd)/.venv/bin/sphinx-build 8 | 9 | 10 | .venv: pyproject.toml uv.lock 11 | which uv >/dev/null || python3 -m pip install -U uv 12 | uv sync --extra dev 13 | touch -f .venv 14 | 15 | init: .venv 16 | .PHONY: init 17 | 18 | 19 | tests: .venv 20 | $(BIN)/tox 21 | .PHONY: tests 22 | 23 | # Alias for old-style invocation 24 | test: tests 25 | .PHONY: test 26 | 27 | coverage: .venv 28 | $(BIN)/coverage run -m unittest discover -t . -s tests 29 | $(BIN)/coverage xml 30 | .PHONY: coverage 31 | 32 | build: 33 | $(UV) build 34 | .PHONY: build 35 | 36 | clean-docs: 37 | cd docs; make clean 38 | .PHONY: clean-docs 39 | 40 | clean: clean-docs 41 | rm -rf *.egg-info .mypy_cache coverage.xml .venv 42 | find . -name "*.pyc" -type f -delete 43 | find . -type d -empty -delete 44 | # Legacy venv (remove eventually) 45 | rm -rf env 46 | .PHONY: clean-python 47 | 48 | 49 | docs: .venv 50 | cd docs; make html SPHINXBUILD=$(SPHINXBUILD); make man SPHINXBUILD=$(SPHINXBUILD); make doctest SPHINXBUILD=$(SPHINXBUILD) 51 | 52 | lint: .venv 53 | $(BIN)/ruff check hl7 tests 54 | CHECK_ONLY=true $(MAKE) format 55 | .PHONY: lint 56 | 57 | CHECK_ONLY ?= 58 | ifdef CHECK_ONLY 59 | RUFF_FORMAT_ARGS=--check 60 | endif 61 | format: .venv 62 | $(BIN)/ruff format $(RUFF_FORMAT_ARGS) hl7 tests 63 | 64 | .PHONY: format 65 | 66 | upload: 67 | rm -rf dist 68 | $(UV) build 69 | $(UV) publish 70 | .PHONY: upload 71 | 72 | bump: .venv 73 | $(BIN)/cz bump 74 | .PHONY: bump 75 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-hl7 - HL7 2.x Parsing 2 | ============================ 3 | 4 | python-hl7 is a simple library for parsing messages of Health Level 7 5 | (HL7) version 2.x into Python objects. 6 | 7 | * Source Code: http://github.com/johnpaulett/python-hl7 8 | * Documentation: http://python-hl7.readthedocs.org 9 | * PyPi: http://pypi.python.org/pypi/hl7 10 | 11 | python-hl7 supports Python 3.9 through 3.13. 12 | 13 | .. image:: 14 | https://github.com/johnpaulett/python-hl7/workflows/Python%20package/badge.svg 15 | :target: https://github.com/johnpaulett/python-hl7/actions 16 | 17 | 18 | .. warning:: 19 | 20 | python-hl7 v0.3.0 breaks `backwards compatibility 21 | `_. 22 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /_build/ 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-hl7.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-hl7.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-hl7" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-hl7" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpaulett/python-hl7/26022d555ca18f28d40490aa43d1d2f30aeeaeb2/docs/_static/.gitignore -------------------------------------------------------------------------------- /docs/_templates/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpaulett/python-hl7/26022d555ca18f28d40490aa43d1d2f30aeeaeb2/docs/_templates/.gitignore -------------------------------------------------------------------------------- /docs/accessors.rst: -------------------------------------------------------------------------------- 1 | Message Accessor 2 | ================ 3 | 4 | Reproduced from: http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 5 | 6 | .. note:: 7 | 8 | Warning: Indexes in this API are from 1, not 0. This is to align with the HL7 documentation. 9 | 10 | Example HL7 Fragment: 11 | 12 | .. doctest:: 13 | 14 | >>> message = 'MSH|^~\&|\r' 15 | >>> message += 'PID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2\r\r' 16 | 17 | >>> import hl7 18 | >>> h = hl7.parse(message) 19 | 20 | The resulting parse tree with values in quotes: 21 | 22 | | Segment = "PID" 23 | | F1 24 | | R1 = "Field1" 25 | | F2 26 | | R1 27 | | C1 = "Component1" 28 | | C2 = "Component2" 29 | | F3 30 | | R1 31 | | C1 = "Component1" 32 | | C2 33 | | S1 = "Sub-Component1" 34 | | S2 = "Sub-Component2" 35 | | C3 = "Component3" 36 | | F4 37 | | R1 = "Repeat1" 38 | | R2 = "Repeat2" 39 | 40 | | Legend 41 | | 42 | | F Field 43 | | R Repeat 44 | | C Component 45 | | S Sub-Component 46 | 47 | A tree has leaf values and nodes. Only the leaves of the tree can have a value. 48 | All data items in the message will be in a leaf node. 49 | 50 | After parsing, the data items in the message are in position in the parse tree, but 51 | they remain in their escaped form. To extract a value from the tree you start at the 52 | root of the Segment and specify the details of which field value you want to extract. 53 | The minimum specification is the field number and repeat number. If you are after a 54 | component or sub-component value you also have to specify these values. 55 | 56 | If for instance if you want to read the value "Sub-Component2" from the example HL7 57 | you need to specify: Field 3, Repeat 1, Component 2, Sub-Component 2 (PID.F1.R1.C2.S2). 58 | Reading values from a tree structure in this manner is the only safe way to read data 59 | from a message. 60 | 61 | .. doctest:: 62 | 63 | >>> h['PID.F1.R1'] 64 | 'Field1' 65 | 66 | >>> h['PID.F2.R1.C1'] 67 | 'Component1' 68 | 69 | You can also access values using :py:class:`hl7.Accessor`, or by directly calling 70 | :py:meth:`hl7.Message.extract_field`. The following are all equivalent: 71 | 72 | .. doctest:: 73 | 74 | >>> h['PID.F2.R1.C1'] 75 | 'Component1' 76 | 77 | >>> h[hl7.Accessor('PID', 1, 2, 1, 1)] 78 | 'Component1' 79 | 80 | >>> h.extract_field('PID', 1, 2, 1, 1) 81 | 'Component1' 82 | 83 | All values should be accessed in this manner. Even if a field is marked as being 84 | non-repeating a repeat of "1" should be specified as later version messages 85 | could have a repeating value. 86 | 87 | To enable backward and forward compatibility there are rules for reading values when the 88 | tree does not match the specification (eg PID.F1.R1.C2.S2) The common example of this is 89 | expanding a HL7 "IS" Value into a Codeded Value ("CE"). Systems reading a "IS" value would 90 | read the Identifier field of a message with a "CE" value and systems expecting a "CE" value 91 | would see a Coded Value with only the identifier specified. A common Australian example of 92 | this is the OBX Units field, which was an "IS" value previously and became a "CE" Value 93 | in later versions. 94 | 95 | | Old Version: "\|mmol/l\|" New Version: "\|mmol/l^^ISO+\|" 96 | 97 | Systems expecting a simple "IS" value would read "OBX.F6.R1" and this would yield a value 98 | in the tree for an old message but with a message with a Coded Value that tree node would 99 | not have a value, but would have 3 child Components with the "mmol/l" value in the first 100 | subcomponent. To resolve this issue where the tree is deeper than the specified path the 101 | first node of every child node is traversed until a leaf node is found and that value is 102 | returned. 103 | 104 | .. doctest:: 105 | 106 | >>> h['PID.F3.R1.C2'] 107 | 'Sub-Component1' 108 | 109 | This is a general rule for reading values: **If the parse tree is deeper than the specified 110 | path continue following the first child branch until a leaf of the tree is encountered 111 | and return that value (which could be blank).** 112 | 113 | Systems expecting a Coded Value ("CE"), but reading a message with a simple "IS" value in it 114 | have the opposite problem. They have a deeper specification but have reached a leaf node and 115 | cannot follow the path any further. Reading a "CE" value requires multiple reads for each 116 | sub-component but for the "Identifier" in this example the specification would be "OBX.F6.R1.C1". 117 | The tree would stop at R1 so C1 would not exist. In this case the unsatisfied path elements 118 | (C1 in this case) can be examined and if every one is position 1 then they can be ignored and 119 | the leaf of the tree that was reached returned. If any of the unsatisfied paths are not in 120 | position 1 then this cannot be done and the result is a blank string. 121 | 122 | This is the second Rule for reading values: **If the parse tree terminates before the full path 123 | is satisfied check each of the subsequent paths and if every one is specified at position 1 124 | then the leaf value reached can be returned as the result.** 125 | 126 | .. doctest:: 127 | 128 | >>> h['PID.F1.R1.C1.S1'] 129 | 'Field1' 130 | 131 | This is a general rule for reading values: **If the parse tree is deeper than the specified 132 | path continue following the first child branch until a leaf of the tree is encountered 133 | and return that value (which could be blank).** 134 | 135 | In the second example every value that makes up the Coded Value, other than the identifier 136 | has a component position greater than one and when reading a message with a simple "IS" 137 | value in it, every value other than the identifier would return a blank string. 138 | 139 | Following these rules will result in excellent backward and forward compatibility. It is 140 | important to allow the reading of values that do not exist in the parse tree by simply 141 | returning a blank string. The two rules detailed above, along with the full tree specification 142 | for all values being read from a message will eliminate many of the errors seen when 143 | handling earlier and later message versions. 144 | 145 | .. doctest:: 146 | 147 | >>> h['PID.F10.R1'] 148 | '' 149 | 150 | 151 | At this point the desired value has either been located, or is absent, in which case a blank 152 | string is returned. 153 | 154 | Assignments 155 | ----------- 156 | 157 | The accessors also support item assignments. However, the Message object must exist and the 158 | separators must be validly assigned. 159 | 160 | Create a response message. 161 | 162 | .. doctest:: 163 | 164 | >>> SEP = '|^~\&' 165 | >>> CR_SEP = '\r' 166 | >>> MSH = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ['MSH'])]) 167 | >>> MSA = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ['MSA'])]) 168 | >>> response = hl7.Message(CR_SEP, [MSH, MSA]) 169 | >>> response['MSH.F1.R1'] = SEP[0] 170 | >>> response['MSH.F2.R1'] = SEP[1:] 171 | 172 | >>> str(response) 173 | 'MSH|^~\\&|\rMSA\r' 174 | 175 | Assign values into the message. You can only assign a string into the message (i.e. a leaf 176 | of the tree). 177 | 178 | .. doctest:: 179 | 180 | >>> response['MSH.F9.R1.C1'] = 'ORU' 181 | >>> response['MSH.F9.R1.C2'] = 'R01' 182 | >>> response['MSH.F9.R1.C3'] = '' 183 | >>> response['MSH.F12.R1'] = '2.4' 184 | >>> response['MSA.F1.R1'] = 'AA' 185 | >>> response['MSA.F3.R1'] = 'Application Message' 186 | 187 | >>> str(response) 188 | 'MSH|^~\\&|||||||ORU^R01^|||2.4\rMSA|AA||Application Message\r' 189 | 190 | You can also assign values using :py:class:`hl7.Accessor`, or by directly calling 191 | :py:meth:`hl7.Message.assign_field`. The following are all equivalent: 192 | 193 | .. doctest:: 194 | 195 | >>> response['MSA.F1.R1'] = 'AA' 196 | >>> response[hl7.Accessor('MSA', 1, 1, 1)] = 'AA' 197 | >>> response.assign_field('AA', 'MSA', 1, 1, 1) 198 | 199 | Escaping Content 200 | ---------------- 201 | 202 | HL7 messages are transported using the 7bit ascii character set. Only characters between 203 | ascii 32 and 127 are used. Characters which cannot be transported using this range 204 | of values must be 'escaped', that is replaced by a sequence of characters for transmission. 205 | 206 | The stores values internally in the escaped format. When the message is composed using 207 | 'str', the escaped value must be returned. 208 | 209 | .. doctest:: 210 | 211 | >>> message = 'MSH|^~\&|\r' 212 | >>> message += 'PID|Field1|\F\|\r\r' 213 | >>> h = hl7.parse(message) 214 | 215 | >>> str(h['PID'][0][2]) 216 | '\\F\\' 217 | 218 | >>> h.unescape(str(h['PID'][0][2])) 219 | '|' 220 | 221 | When the accessor is used to reference the field, the field is automatically unescaped. 222 | 223 | .. doctest:: 224 | 225 | >>> h['PID.F2.R1'] 226 | '|' 227 | 228 | The escape/unescape mechanism support replacing separator characters with their escaped 229 | version and replacing non-ascii characters with hexadecimal versions. 230 | 231 | The escape method returns a 'str' object. The unescape method returns a str object. 232 | 233 | .. doctest:: 234 | 235 | >>> h.unescape('\\F\\') 236 | '|' 237 | 238 | >>> h.unescape('\\R\\') 239 | '~' 240 | 241 | >>> h.unescape('\\S\\') 242 | '^' 243 | 244 | >>> h.unescape('\\T\\') 245 | '&' 246 | 247 | >>> h.unescape('\\X202020\\') 248 | ' ' 249 | 250 | >>> h.escape('|~^&') 251 | '\\F\\\\R\\\\S\\\\T\\' 252 | 253 | >>> h.escape('áéíóú') 254 | '\\Xe1\\\\Xe9\\\\Xed\\\\Xf3\\\\Xfa\\' 255 | 256 | **Presentation Characters** 257 | 258 | HL7 defines a protocol for encoding presentation characters, These include highlighting, 259 | and rich text functionality. The API does not currently allow for easy access to the 260 | escape/unescape logic. You must overwrite the message class escape and unescape methods, 261 | after parsing the message. 262 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | python-hl7 API 2 | ============== 3 | 4 | .. testsetup:: * 5 | 6 | import hl7 7 | message = 'MSH|^~\&|GHH LAB|ELAB-3|GHH OE|BLDG4|200202150930||ORU^R01|CNTRL-3456|P|2.4\r' 8 | message += 'PID|||555-44-4444||EVERYWOMAN^EVE^E^^^^L|JONES|196203520|F|||153 FERNWOOD DR.^^STATESVILLE^OH^35292||(206)3345232|(206)752-121||||AC555444444||67-A4335^OH^20030520\r' 9 | message += 'OBR|1|845439^GHH OE|1045813^GHH LAB|1554-5^GLUCOSE|||200202150730||||||||555-55-5555^PRIMARY^PATRICIA P^^^^MD^^LEVEL SEVEN HEALTHCARE, INC.|||||||||F||||||444-44-4444^HIPPOCRATES^HOWARD H^^^^MD\r' 10 | message += 'OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F\r' 11 | 12 | .. autodata:: hl7.NULL 13 | 14 | .. autofunction:: hl7.parse 15 | 16 | .. autofunction:: hl7.parse_batch 17 | 18 | .. autofunction:: hl7.parse_file 19 | 20 | .. autofunction:: hl7.parse_hl7 21 | 22 | .. autofunction:: hl7.ishl7 23 | 24 | .. autofunction:: hl7.isbatch 25 | 26 | .. autofunction:: hl7.isfile 27 | 28 | .. autofunction:: hl7.split_file 29 | 30 | .. autofunction:: hl7.generate_message_control_id 31 | 32 | .. autofunction:: hl7.parse_datetime 33 | 34 | 35 | Data Types 36 | ---------- 37 | 38 | .. autoclass:: hl7.Sequence 39 | :members: __call__ 40 | 41 | .. autoclass:: hl7.Container 42 | :members: __str__ 43 | 44 | .. autoclass:: hl7.Accessor 45 | :members: __new__, parse_key, key, _replace, _make, _asdict, segment, segment_num, field_num, repeat_num, component_num, subcomponent_num 46 | 47 | .. autoclass:: hl7.Batch 48 | :members: __str__, header, trailer, create_header, create_trailer, create_file, create_batch, create_message, create_segment, create_field, create_repetition, create_component 49 | 50 | .. autoclass:: hl7.File 51 | :members: __str__, header, trailer, create_header, create_trailer, create_file, create_batch, create_message, create_segment, create_field, create_repetition, create_component 52 | 53 | .. autoclass:: hl7.Message 54 | :members: segments, segment, __getitem__, __setitem__, __str__, escape, unescape, extract_field, assign_field, create_file, create_batch, create_message, create_segment, create_field, create_repetition, create_component, create_ack 55 | 56 | .. autoclass:: hl7.Segment 57 | 58 | .. autoclass:: hl7.Field 59 | 60 | .. autoclass:: hl7.Repetition 61 | 62 | .. autoclass:: hl7.Component 63 | 64 | .. autoclass:: hl7.Factory 65 | :members: 66 | 67 | 68 | MLLP Network Client 69 | ------------------- 70 | 71 | .. autoclass:: hl7.client.MLLPClient 72 | :members: send_message, send, close 73 | 74 | MLLP Asyncio 75 | ------------ 76 | 77 | .. autofunction:: hl7.mllp.open_hl7_connection 78 | 79 | .. autofunction:: hl7.mllp.start_hl7_server 80 | 81 | .. autoclass:: hl7.mllp.HL7StreamReader 82 | :members: readmessage 83 | 84 | .. autoclass:: hl7.mllp.HL7StreamWriter 85 | :members: writemessage 86 | 87 | .. autoclass:: hl7.mllp.InvalidBlockError 88 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | .. include:: ../AUTHORS 5 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.4.6 - Unreleased 5 | ------------------ 6 | 7 | * Dropped support for Python 3.7 and 3.8. Python 3.9 - 3.13 are now supported. 8 | * Switched development dependencies to be installed using ``uv`` instead of ``pip``. 9 | * Converted packaging to ``pyproject.toml`` using ``hatchling`` and removed 10 | ``requirements.txt``. 11 | * Added ``commitizen`` to manage version bumps and changelog entries. 12 | * Added ``make bump`` target and replaced ``make env`` with ``make init`` for 13 | setting up the development environment. 14 | * Removed deprecated ``hl7.version`` module; use ``hl7.__version__`` instead. 15 | * Fixed a bug in `mllp_send --quiet` -- thanks `Spencer Vecile `_! 16 | * Switched formatting and linting from black/isort and flake8 to ``ruff``. 17 | * Fixed timezone formatting when ``parse_datetime`` created ``_UTCOffset`` with a 18 | float value. 19 | * Use `socket.sendall` to flush the MLLP buffer. `python-hl7#41 `_. 20 | Thanks `Feenes `_!` 21 | 22 | 23 | 0.4.5 - March 2022 24 | --------------------- 25 | 26 | * Better support for :py:class:`HL7StreamProtocol` in Python 3.7, which lacks 27 | `_reject_connection` 28 | 29 | Thanks `Joseph Wortmann `_! 30 | 31 | 32 | 0.4.3 - March 2022 33 | --------------------- 34 | 35 | * Dropped support for Python 3.5 & 3.6. Python 3.7 - 3.10 now supported. 36 | * Ensure :py:func:`hl7.parse_hl7` allows legitimate occurrences of "MSH" inside 37 | the message contents 38 | 39 | Thanks `Andrew Wason `_! 40 | 41 | 42 | 0.4.2 - February 2021 43 | --------------------- 44 | 45 | * Added support for :py:class:`hl7.Batch` and :py:class:`hl7.File`, via 46 | :py:func:`hl7.parse_hl7` or the more specific :py:func:`hl7.parse_batch` 47 | and :py:func:`parse_file`. 48 | 49 | Thanks `Joseph Wortmann `_! 50 | 51 | 52 | 0.4.1 - September 2020 53 | ---------------------- 54 | 55 | * Experimental asyncio-based HL7 MLLP support. :doc:`mllp`, via 56 | :py:func:`hl7.mllp.open_hl7_connection` and 57 | :py:func:`hl7.mllp.start_hl7_server` 58 | 59 | Thanks `Joseph Wortmann `_! 60 | 61 | 62 | .. _changelog-0-4-0: 63 | 64 | 0.4.0 - September 2020 65 | ---------------------- 66 | 67 | * Message now ends with trailing carriage return, to be consistent with Message 68 | Construction Rules (Section 2.6, v2.8). [`python-hl7#26 `] 69 | * Handle ASCII characters within :py:meth:`hl7.Message.escape` under Python 3. 70 | * Don't escape MSH-2 so that the control characters are retrievable. [`python-hl7#27 `] 71 | * Add MSH-9.1.3 to create_ack. 72 | * Dropped support for Python 2.7, 3.3, & 3.4. Python 3.5 - 3.8 now supported. 73 | * Converted code style to use black. 74 | 75 | Thanks `Lucas Kahlert `_ & 76 | `Joseph Wortmann `_! 77 | 78 | 79 | 0.3.5 - June 2020 80 | ----------------- 81 | * Handle ASCII characters within :py:meth:`hl7.Message.escape` under Python 3. 82 | 83 | Thanks `Lucas Kahlert `_! 84 | 85 | 86 | 0.3.4 - June 2016 87 | ----------------- 88 | * Fix bug under Python 3 when writing to stdout from `mllp_send` 89 | * Publish as a Python wheel 90 | 91 | 92 | 0.3.3 - June 2015 93 | ----------------- 94 | * Expose a Factory that allows control over the container subclasses created 95 | to construct a message 96 | * Split up single module into more manageable submodules. 97 | 98 | Thanks `Andrew Wason `_! 99 | 100 | 101 | 0.3.2 - September 2014 102 | ---------------------- 103 | * New :py:func:`hl7.parse_datetime` for parsing HL7 DTM into python 104 | :py:class:`datetime.datetime`. 105 | 106 | 107 | 0.3.1 - August 2014 108 | ------------------- 109 | 110 | * Allow HL7 ACK's to be generated from an existing Message via 111 | :py:meth:`hl7.Message.create_ack` 112 | 113 | .. _changelog-0-3-0: 114 | 115 | 0.3.0 - August 2014 116 | ------------------- 117 | 118 | .. warning:: 119 | 120 | :ref:`0.3.0 ` breaks backwards compatibility by correcting 121 | the indexing of the MSH segment and the introducing improved parsing down to 122 | the repetition and sub-component level. 123 | 124 | 125 | * Changed the numbering of fields in the MSH segment. 126 | **This breaks older code.** 127 | * Parse all the elements of the message (i.e. down to sub-component). **The 128 | inclusion of repetitions will break older code.** 129 | * Implemented a basic escaping mechanism 130 | * New constant 'NULL' which maps to '""' 131 | * New :py:func:`hl7.isfile` and :py:func:`hl7.split_file` functions to 132 | identify file (FHS/FTS) wrapped messages 133 | * New mechanism to address message parts via a :doc:`symbolic accessor name 134 | ` 135 | * Message (and Message.segments), Field, Repetition and Component can be 136 | accessed using 1-based indices by using them as a callable. 137 | * Added Python 3 support. Python 2.6, 2.7, and 3.3 are officially supported. 138 | * :py:func:`hl7.parse` can now decode byte strings, using the ``encoding`` 139 | parameter. :py:class:`hl7.client.MLLPClient` can now encode unicode input 140 | using the ``encoding`` parameter. To support Python 3, unicode is now 141 | the primary string type used inside the library. bytestrings are only 142 | allowed at the edge of the library now, with ``hl7.parse`` and sending 143 | via ``hl7.client.MLLPClient``. Refer to :ref:`unicode-vs-byte-strings`. 144 | * Testing via tox and travis CI added. See :doc:`contribute`. 145 | 146 | A massive thanks to `Kevin Gill `_ and 147 | `Emilien Klein `_ for the initial code submissions 148 | to add the improved parsing, and to 149 | `Andrew Wason `_ for rebasing the initial pull 150 | request and providing assistance in the transition. 151 | 152 | 153 | 0.2.5 - March 2012 154 | ------------------ 155 | 156 | * Do not senselessly try to convert to unicode in mllp_send. Allows files to 157 | contain other encodings. 158 | 159 | 0.2.4 - February 2012 160 | --------------------- 161 | 162 | * ``mllp_send --version`` prints version number 163 | * ``mllp_send --loose`` algorithm modified to allow multiple messages per file. 164 | The algorithm now splits messages based upon the presumed start of a message, 165 | which must start with ``MSH|^~\&|`` 166 | 167 | 0.2.3 - January 2012 168 | -------------------- 169 | 170 | * ``mllp_send --loose`` accepts & converts Unix newlines in addition to 171 | Windows newlines 172 | 173 | 0.2.2 - December 2011 174 | --------------------- 175 | 176 | * :ref:`mllp_send ` now takes the ``--loose`` options, which allows 177 | sending HL7 messages that may not exactly meet the standard (Windows newlines 178 | separating segments instead of carriage returns). 179 | 180 | 0.2.1 - August 2011 181 | ------------------- 182 | 183 | * Added MLLP client (:py:class:`hl7.client.MLLPClient`) and command line tool, 184 | :ref:`mllp_send `. 185 | 186 | 0.2.0 - June 2011 187 | ----------------- 188 | 189 | * Converted ``hl7.segment`` and ``hl7.segments`` into methods on 190 | :py:class:`hl7.Message`. 191 | * Support dict-syntax for getting Segments from a Message (e.g. ``message['OBX']``) 192 | * Use unicode throughout python-hl7 since the HL7 spec allows non-ASCII characters. 193 | It is up to the caller of :py:func:`hl7.parse` to convert non-ASCII messages 194 | into unicode. 195 | * Refactored from single hl7.py file into the hl7 module. 196 | * Added Sphinx `documentation `_. 197 | Moved project to `github `_. 198 | 199 | 0.1.1 - June 2009 200 | ----------------- 201 | 202 | * Apply Python 3 trove classifier 203 | 204 | 0.1.0 - March 2009 205 | ------------------ 206 | 207 | * Support message-defined separation characters 208 | * Message, Segment, Field classes 209 | 210 | 0.0.3 - January 2009 211 | -------------------- 212 | 213 | * Initial release 214 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # python-hl7 documentation build configuration file, created by 3 | # sphinx-quickstart on Tue Jul 12 10:57:30 2011. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import os 14 | import sys 15 | from datetime import date 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath("..")) 21 | import hl7 # noqa: E402 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.intersphinx"] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ["_templates"] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = ".rst" 37 | 38 | # The encoding of source files. 39 | # source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = "index" 43 | 44 | # General information about the project. 45 | project = "python-hl7" 46 | copyright = "2011-{}, John Paulett".format(date.today().year) 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = hl7.__version__ 54 | # The full version, including alpha/beta/rc tags. 55 | release = hl7.__version__ 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | # today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | # today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ["_build"] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | # default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | # add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | # add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | # show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = "sphinx" 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | # modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = "alabaster" 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | html_theme_options = { 102 | "description": "Easy HL7 v2.x parsing", 103 | "github_user": "johnpaulett", 104 | "github_repo": "python-hl7", 105 | "codecov_button": True, 106 | "github_banner": True, 107 | "badge_branch": "main", 108 | # "page_width": "940", 109 | } 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | # html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | # html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | # html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | # html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | # html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ["_static"] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | # html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | # html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | # html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | # html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | # html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | # html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | # html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | # html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | # html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | # html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | # html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | # html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = "python-hl7doc" 178 | 179 | 180 | # -- Options for LaTeX output -------------------------------------------------- 181 | 182 | # The paper size ('letter' or 'a4'). 183 | # latex_paper_size = 'letter' 184 | 185 | # The font size ('10pt', '11pt' or '12pt'). 186 | # latex_font_size = '10pt' 187 | 188 | # Grouping the document tree into LaTeX files. List of tuples 189 | # (source start file, target name, title, author, documentclass [howto/manual]). 190 | latex_documents = [ 191 | ("index", "python-hl7.tex", "python-hl7 Documentation", "John Paulett", "manual"), 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | # latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | # latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | # latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | # latex_show_urls = False 207 | 208 | # Additional stuff for the LaTeX preamble. 209 | # latex_preamble = '' 210 | 211 | # Documents to append as an appendix to all manuals. 212 | # latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | # latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [("mllp_send", "mllp_send", "MLLP network client", ["John Paulett"], 1)] 223 | 224 | 225 | # -- Options for Epub output --------------------------------------------------- 226 | 227 | # Bibliographic Dublin Core info. 228 | epub_title = "python-hl7" 229 | epub_author = "John Paulett" 230 | epub_publisher = "John Paulett" 231 | epub_copyright = "2011, John Paulett" 232 | 233 | # The language of the text. It defaults to the language option 234 | # or en if the language is not set. 235 | # epub_language = '' 236 | 237 | # The scheme of the identifier. Typical schemes are ISBN or URL. 238 | # epub_scheme = '' 239 | 240 | # The unique identifier of the text. This can be a ISBN number 241 | # or the project homepage. 242 | # epub_identifier = '' 243 | 244 | # A unique identification for the text. 245 | # epub_uid = '' 246 | 247 | # HTML files that should be inserted before the pages created by sphinx. 248 | # The format is a list of tuples containing the path and title. 249 | # epub_pre_files = [] 250 | 251 | # HTML files that should be inserted after the pages created by sphinx. 252 | # The format is a list of tuples containing the path and title. 253 | # epub_post_files = [] 254 | 255 | # A list of files that should not be packed into the epub file. 256 | # epub_exclude_files = [] 257 | 258 | # The depth of the table of contents in toc.ncx. 259 | # epub_tocdepth = 3 260 | 261 | # Allow duplicate toc entries. 262 | # epub_tocdup = True 263 | 264 | 265 | # Example configuration for intersphinx: refer to the Python standard library. 266 | intersphinx_mapping = {"http://docs.python.org/": None} 267 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | The source code is available at http://github.com/johnpaulett/python-hl7 5 | 6 | Please fork and issue pull requests. Generally any changes, bug fixes, or 7 | new features should be accompanied by corresponding tests in our test 8 | suite. 9 | 10 | 11 | Testing 12 | -------- 13 | 14 | The test suite is located in :file:`tests/` and can be run several ways. 15 | 16 | It is recommended to run the full `tox `_ suite so 17 | that all supported Python versions are tested and the documentation is built 18 | and tested. We provide a :file:`Makefile` that uses ``uv`` to create a 19 | virtual environment. Initialize the environment and run tox:: 20 | 21 | $ make init 22 | $ make tests 23 | py311: commands succeeded 24 | py310: commands succeeded 25 | py39: commands succeeded 26 | docs: commands succeeded 27 | congratulations :) 28 | 29 | To run the test suite with a specific Python interpreter:: 30 | 31 | python -m unittest discover -t . -s tests 32 | 33 | To documentation is built by tox, but you can manually build via:: 34 | 35 | $ make docs 36 | ... 37 | Doctest summary 38 | =============== 39 | 23 tests 40 | 0 failures in tests 41 | 0 failures in setup code 42 | ... 43 | 44 | 45 | Formatting 46 | ---------- 47 | 48 | python-hl7 uses `ruff `_ to enforce a coding 49 | style. To automatically format the code:: 50 | 51 | $ make format 52 | 53 | It is also recommended to run the lint checks with ``ruff``. 54 | Commits should be free of warnings:: 55 | 56 | $ make lint 57 | 58 | Releases 59 | -------- 60 | 61 | `Commitizen `_ is used to 62 | manage project versions and the changelog. After changes are merged to the 63 | main branch, bump the version and update ``docs/changelog.rst`` with:: 64 | 65 | $ make bump 66 | 67 | This uses ``cz bump`` to update ``pyproject.toml`` and ``hl7/__init__.py`` with the new version. 68 | 69 | Build the release artifacts and publish to PyPI using:: 70 | 71 | $ make build 72 | $ make upload 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | python-hl7 - Easy HL7 v2.x Parsing 2 | ================================== 3 | 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 `). 8 | 9 | HL7 is a communication protocol and message format for 10 | health care data. It is the de-facto standard for transmitting data 11 | between clinical information systems and between clinical devices. 12 | The version 2.x series, which is often in a pipe delimited format, 13 | is currently the most widely accepted version of HL7 (there 14 | is an alternative XML-based format). 15 | 16 | python-hl7 currently only parses HL7 version 2.x messages into 17 | an easy to access data structure. The library could eventually 18 | also contain the ability to create HL7 v2.x messages. 19 | 20 | python-hl7 parses HL7 into a series of wrapped :py:class:`hl7.Container` objects. 21 | There are specific subclasses of :py:class:`hl7.Container` depending on 22 | the part of the HL7 message. The :py:class:`hl7.Container` message itself 23 | is a subclass of a Python list, thus we can easily access the 24 | HL7 message as an n-dimensional list. Specifically, the subclasses of 25 | :py:class:`hl7.Container`, in order, are :py:class:`hl7.Message`, 26 | :py:class:`hl7.Segment`, :py:class:`hl7.Field`, :py:class:`hl7.Repetition`. 27 | and :py:class:`hl7.Component`. 28 | 29 | python-hl7 includes experimental asyncio-based HL7 MLLP support in :doc:`mllp`, 30 | which aims to replace `txHL7 `_. 31 | 32 | 33 | .. image:: 34 | https://github.com/johnpaulett/python-hl7/workflows/Python%20package/badge.svg 35 | :target: https://github.com/johnpaulett/python-hl7/actions 36 | 37 | 38 | 39 | Result Tree 40 | ----------- 41 | 42 | HL7 messages have a limited number of levels. The top level is a 43 | :py:class:`hl7.Message`. A message is comprised of a number of 44 | :py:class:`hl7.Segment` objects. Each segment contains a series of 45 | :py:class:`hl7.Field` objects. Fields can repeat (:py:class:`hl7.Repetition`). 46 | The content of a field is either a primitive data type (such as a string) 47 | or a composite data type comprised of one or more 48 | :py:class:`hl7.Component` objects. Components are in turn comprised of 49 | sub-components (primitive data types). 50 | 51 | The result of parsing is accessed as a tree using python list conventions: 52 | 53 | ``Message[segment][field][repetition][component][sub-component]`` 54 | 55 | The result can also be accessed using HL7 1-based indexing conventions by treating 56 | each element as a callable: 57 | 58 | ``Message(segment)(field)(repetition)(component)(sub-component)`` 59 | 60 | 61 | Usage 62 | ----- 63 | 64 | As an example, let's create a HL7 message: 65 | 66 | .. doctest:: 67 | 68 | >>> message = 'MSH|^~\&|GHH LAB|ELAB-3|GHH OE|BLDG4|200202150930||ORU^R01|CNTRL-3456|P|2.4\r' 69 | >>> message += 'PID|||555-44-4444||EVERYWOMAN^EVE^E^^^^L|JONES|196203520|F|||153 FERNWOOD DR.^^STATESVILLE^OH^35292||(206)3345232|(206)752-121||||AC555444444||67-A4335^OH^20030520\r' 70 | >>> message += 'OBR|1|845439^GHH OE|1045813^GHH LAB|1554-5^GLUCOSE|||200202150730||||||||555-55-5555^PRIMARY^PATRICIA P^^^^MD^^LEVEL SEVEN HEALTHCARE, INC.|||||||||F||||||444-44-4444^HIPPOCRATES^HOWARD H^^^^MD\r' 71 | >>> message += 'OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F\r' 72 | 73 | We call the :py:func:`hl7.parse` command with string message: 74 | 75 | .. doctest:: 76 | 77 | >>> import hl7 78 | >>> h = hl7.parse(message) 79 | 80 | We get a :py:class:`hl7.Message` object, wrapping a series of 81 | :py:class:`hl7.Segment` objects: 82 | 83 | .. doctest:: 84 | 85 | >>> type(h) 86 | 87 | 88 | We can always get the HL7 message back: 89 | 90 | .. doctest:: 91 | 92 | >>> str(h) == message 93 | True 94 | 95 | Interestingly, :py:class:`hl7.Message` can be accessed as a list: 96 | 97 | .. doctest:: 98 | 99 | >>> isinstance(h, list) 100 | True 101 | 102 | There were 4 segments (MSH, PID, OBR, OBX): 103 | 104 | .. doctest:: 105 | 106 | >>> len(h) 107 | 4 108 | 109 | We can extract the :py:class:`hl7.Segment` from the 110 | :py:class:`hl7.Message` instance: 111 | 112 | .. doctest:: 113 | 114 | >>> h[3] 115 | [['OBX'], ['1'], ['SN'], [[['1554-5'], ['GLUCOSE'], ['POST 12H CFST:MCNC:PT:SER/PLAS:QN']]], [''], [[[''], ['182']]], ['mg/dl'], ['70_105'], ['H'], [''], [''], ['F']] 116 | >>> h[3] is h(4) 117 | True 118 | 119 | Note that since the first element of the segment is the segment name, 120 | segments are effectively 1-based in python as well (because the HL7 spec does 121 | not count the segment name as part of the segment itself): 122 | 123 | .. doctest:: 124 | 125 | >>> h[3][0] 126 | ['OBX'] 127 | >>> h[3][1] 128 | ['1'] 129 | >>> h[3][2] 130 | ['SN'] 131 | >>> h(4)(2) 132 | ['SN'] 133 | 134 | We can easily reconstitute this segment as HL7, using the 135 | appropriate separators: 136 | 137 | .. doctest:: 138 | 139 | >>> str(h[3]) 140 | 'OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F' 141 | 142 | We can extract individual elements of the message: 143 | 144 | .. doctest:: 145 | 146 | >>> h[3][3][0][1][0] 147 | 'GLUCOSE' 148 | >>> h[3][3][0][1][0] is h(4)(3)(1)(2)(1) 149 | True 150 | >>> h[3][5][0][1][0] 151 | '182' 152 | >>> h[3][5][0][1][0] is h(4)(5)(1)(2)(1) 153 | True 154 | 155 | We can look up segments by the segment identifier, either via 156 | :py:meth:`hl7.Message.segments` or via the traditional dictionary 157 | syntax: 158 | 159 | .. doctest:: 160 | 161 | >>> h.segments('OBX')[0][3][0][1][0] 162 | 'GLUCOSE' 163 | >>> h['OBX'][0][3][0][1][0] 164 | 'GLUCOSE' 165 | >>> h['OBX'][0][3][0][1][0] is h['OBX'](1)(3)(1)(2)(1) 166 | True 167 | 168 | Since many many types of segments only have a single instance in a message 169 | (e.g. PID or MSH), :py:meth:`hl7.Message.segment` provides a convenience 170 | wrapper around :py:meth:`hl7.Message.segments` that returns the first matching 171 | :py:class:`hl7.Segment`: 172 | 173 | .. doctest:: 174 | 175 | >>> h.segment('PID')[3][0] 176 | '555-44-4444' 177 | >>> h.segment('PID')[3][0] is h.segment('PID')(3)(1) 178 | True 179 | 180 | The result of parsing contains up to 5 levels. The last level is a non-container 181 | type. 182 | 183 | .. doctest:: 184 | 185 | >>> type(h) 186 | 187 | 188 | >>> type(h[3]) 189 | 190 | 191 | >>> type(h[3][3]) 192 | 193 | 194 | >>> type(h[3][3][0]) 195 | 196 | 197 | >>> type(h[3][3][0][1]) 198 | 199 | 200 | >>> type(h[3][3][0][1][0]) 201 | 202 | 203 | The parser only generates the levels which are present in the message. 204 | 205 | .. doctest:: 206 | 207 | >>> type(h[3][1]) 208 | 209 | 210 | >>> type(h[3][1][0]) 211 | 212 | 213 | MLLP network client - ``mllp_send`` 214 | ----------------------------------- 215 | 216 | python-hl7 features a simple network client, ``mllp_send``, which reads HL7 217 | messages from a file or ``sys.stdin`` and posts them to an MLLP server. 218 | ``mllp_send`` is a command-line wrapper around 219 | :py:class:`hl7.client.MLLPClient`. ``mllp_send`` is a useful tool for 220 | testing HL7 interfaces or resending logged messages:: 221 | 222 | mllp_send --file sample.hl7 --port 6661 mirth.example.com 223 | 224 | See :doc:`mllp_send` for examples and usage instructions. 225 | 226 | For receiving HL7 messages using the Minimal Lower Level Protocol (MLLP), take a 227 | look at the related `twisted-hl7 `_ package. 228 | If do not want to use twisted and are looking to re-write some of twisted-hl7's 229 | functionality, please reach out to us. It is likely that some of the MLLP 230 | parsing and formatting can be moved into python-hl7, which twisted-hl7 and other 231 | libraries can depend upon. 232 | 233 | .. _unicode-vs-byte-strings: 234 | 235 | Python 2 vs Python 3 and Unicode vs Byte strings 236 | ------------------------------------------------- 237 | 238 | python-hl7 supports Python 3.9 through 3.13 and primarily deals with the unicode ``str`` type. 239 | 240 | Passing bytes to :py:func:`hl7.parse`, requires setting the 241 | ``encoding`` parameter, if using anything other than UTF-8. :py:func:`hl7.parse` 242 | will always return a datastructure containing unicode ``str`` objects. 243 | 244 | :py:class:`hl7.Message` can be forced back into a single string using 245 | and ``str(message)``. 246 | 247 | :doc:`mllp_send` assumes the stream is already in the correct encoding. 248 | 249 | :py:class:`hl7.client.MLLPClient`, if given a ``str`` or 250 | :py:class:`hl7.Message` instance, will use its ``encoding`` method 251 | to encode the unicode data into bytes. 252 | 253 | Contents 254 | -------- 255 | 256 | .. toctree:: 257 | :maxdepth: 1 258 | 259 | api 260 | mllp_send 261 | mllp 262 | accessors 263 | contribute 264 | changelog 265 | authors 266 | license 267 | 268 | Install 269 | ------- 270 | 271 | python-hl7 is available on `PyPi `_ 272 | via ``pip`` or ``easy_install``:: 273 | 274 | pip install -U hl7 275 | 276 | For recent versions of Debian and Ubuntu, the *python-hl7* package is 277 | available:: 278 | 279 | sudo apt-get install python-hl7 280 | 281 | Links 282 | ----- 283 | 284 | * Documentation: http://python-hl7.readthedocs.org 285 | * Source Code: http://github.com/johnpaulett/python-hl7 286 | * PyPi: http://pypi.python.org/pypi/hl7 287 | 288 | HL7 References: 289 | 290 | * `Health Level 7 - Wikipedia `_ 291 | * `nule.org's Introduction to HL7 `_ 292 | * `hl7.org `_ 293 | * `OpenMRS's HL7 documentation `_ 294 | * `Transport Specification: MLLP `_ 295 | * `HL7v2 Parsing `_ 296 | * `HL7 Book `_ 297 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../LICENSE 5 | :literal: 6 | 7 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python-hl7.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python-hl7.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/mllp.rst: -------------------------------------------------------------------------------- 1 | MLLP using asyncio 2 | ================== 3 | 4 | .. versionadded:: 0.4.1 5 | 6 | .. note:: 7 | 8 | `hl7.mllp` package is currently experimental and subject to change. 9 | It aims to replace txHL7. 10 | 11 | To use the examples below you need to install the require packages first e.g. `pip install aiorun hl7` 12 | 13 | python-hl7 includes classes for building HL7 clients and 14 | servers using asyncio. The underlying protocol for these 15 | clients and servers is MLLP. 16 | 17 | The `hl7.mllp` package is designed the same as 18 | the `asyncio.streams` package. `Examples in that documentation 19 | `_ 20 | may be of assistance in writing production senders and 21 | receivers. 22 | 23 | HL7 Sender 24 | ---------- 25 | 26 | .. code:: python 27 | 28 | import asyncio 29 | # Using the third party `aiorun` instead of the `asyncio.run()` to avoid 30 | # boilerplate. 31 | import aiorun 32 | 33 | import hl7 34 | from hl7.mllp import open_hl7_connection 35 | 36 | 37 | async def main(): 38 | message = 'MSH|^~\&|GHH LAB|ELAB-3|GHH OE|BLDG4|200202150930||ORU^R01|CNTRL-3456|P|2.4\r' 39 | message += 'PID|||555-44-4444||EVERYWOMAN^EVE^E^^^^L|JONES|196203520|F|||153 FERNWOOD DR.^^STATESVILLE^OH^35292||(206)3345232|(206)752-121||||AC555444444||67-A4335^OH^20030520\r' 40 | message += 'OBR|1|845439^GHH OE|1045813^GHH LAB|1554-5^GLUCOSE|||200202150730||||||||555-55-5555^PRIMARY^PATRICIA P^^^^MD^^LEVEL SEVEN HEALTHCARE, INC.|||||||||F||||||444-44-4444^HIPPOCRATES^HOWARD H^^^^MD\r' 41 | message += 'OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F\r' 42 | 43 | # Open the connection to the HL7 receiver. 44 | # Using wait_for is optional, but recommended so 45 | # a dead receiver won't block you for long 46 | hl7_reader, hl7_writer = await asyncio.wait_for( 47 | open_hl7_connection("127.0.0.1", 2575), 48 | timeout=10, 49 | ) 50 | 51 | hl7_message = hl7.parse(message) 52 | 53 | # Write the HL7 message, and then wait for the writer 54 | # to drain to actually send the message 55 | hl7_writer.writemessage(hl7_message) 56 | await hl7_writer.drain() 57 | print(f'Sent message\n {hl7_message}'.replace('\r', '\n')) 58 | 59 | # Now wait for the ACK message from the receiver 60 | hl7_ack = await asyncio.wait_for( 61 | hl7_reader.readmessage(), 62 | timeout=10 63 | ) 64 | print(f'Received ACK\n {hl7_ack}'.replace('\r', '\n')) 65 | 66 | 67 | aiorun.run(main(), stop_on_unhandled_errors=True) 68 | 69 | HL7 Receiver 70 | ------------ 71 | 72 | .. code:: python 73 | 74 | import asyncio 75 | # Using the third party `aiorun` instead of the `asyncio.run()` to avoid 76 | # boilerplate. 77 | import aiorun 78 | 79 | import hl7 80 | from hl7.mllp import start_hl7_server 81 | 82 | 83 | async def process_hl7_messages(hl7_reader, hl7_writer): 84 | """This will be called every time a socket connects 85 | with us. 86 | """ 87 | peername = hl7_writer.get_extra_info("peername") 88 | print(f"Connection established {peername}") 89 | try: 90 | # We're going to keep listening until the writer 91 | # is closed. Only writers have closed status. 92 | while not hl7_writer.is_closing(): 93 | hl7_message = await hl7_reader.readmessage() 94 | print(f'Received message\n {hl7_message}'.replace('\r', '\n')) 95 | # Now let's send the ACK and wait for the 96 | # writer to drain 97 | hl7_writer.writemessage(hl7_message.create_ack()) 98 | await hl7_writer.drain() 99 | except asyncio.IncompleteReadError: 100 | # Oops, something went wrong, if the writer is not 101 | # closed or closing, close it. 102 | if not hl7_writer.is_closing(): 103 | hl7_writer.close() 104 | await hl7_writer.wait_closed() 105 | print(f"Connection closed {peername}") 106 | 107 | 108 | async def main(): 109 | try: 110 | # Start the server in a with clause to make sure we 111 | # close it 112 | async with await start_hl7_server( 113 | process_hl7_messages, port=2575 114 | ) as hl7_server: 115 | # And now we server forever. Or until we are 116 | # cancelled... 117 | await hl7_server.serve_forever() 118 | except asyncio.CancelledError: 119 | # Cancelled errors are expected 120 | pass 121 | except Exception: 122 | print("Error occurred in main") 123 | 124 | 125 | aiorun.run(main(), stop_on_unhandled_errors=True) 126 | -------------------------------------------------------------------------------- /docs/mllp_send.rst: -------------------------------------------------------------------------------- 1 | .. _mllp-send: 2 | 3 | =================================== 4 | ``mllp_send`` - MLLP network client 5 | =================================== 6 | 7 | 8 | python-hl7 features a simple network client, ``mllp_send``, which reads HL7 9 | messages from a file or ``sys.stdin`` and posts them to an MLLP server. 10 | ``mllp_send`` is a command-line wrapper around 11 | :py:class:`hl7.client.MLLPClient`. ``mllp_send`` is a useful tool for 12 | testing HL7 interfaces or resending logged messages:: 13 | 14 | $ mllp_send --file sample.hl7 --port 6661 mirth.example.com 15 | MSH|^~\&|LIS|Example|Hospital|Mirth|20111207105244||ACK^A01|A234244|P|2.3.1| 16 | MSA|AA|234242|Message Received Successfully| 17 | 18 | 19 | Usage 20 | ===== 21 | :: 22 | 23 | Usage: mllp_send [options] 24 | 25 | Options: 26 | -h, --help show this help message and exit 27 | --version print current version and exit 28 | -p PORT, --port=PORT port to connect to 29 | -f FILE, --file=FILE read from FILE instead of stdin 30 | -q, --quiet do not print status messages to stdout 31 | --loose allow file to be a HL7-like object (\r\n instead of 32 | \r). Requires that messages start with "MSH|^~\&|". 33 | Requires --file option (no stdin) 34 | 35 | Input Format 36 | ============ 37 | 38 | By default, ``mllp_send`` expects the ``FILE`` or stdin input to be a properly 39 | formatted HL7 message (carriage returns separating segments) wrapped in a MLLP 40 | stream (``message1message2...``). 41 | 42 | However, it is common, especially if the file has been manually edited in 43 | certain text editors, that the ASCII control characters will be lost and the 44 | carriage returns will be replaced with the platform's default line endings. 45 | In this case, ``mllp_send`` provides the ``--loose`` option, which attempts 46 | to take something that "looks like HL7" and convert it into a proper HL7 47 | message.. 48 | 49 | 50 | Additional Resources 51 | ==================== 52 | 53 | * http://python-hl7.readthedocs.org 54 | -------------------------------------------------------------------------------- /hl7/__init__.py: -------------------------------------------------------------------------------- 1 | """python-hl7 is a simple library for parsing messages of Health Level 7 2 | (HL7) version 2.x into Python objects. 3 | 4 | * Documentation: http://python-hl7.readthedocs.org 5 | * Source Code: http://github.com/johnpaulett/python-hl7 6 | """ 7 | 8 | from .accessor import Accessor 9 | from .containers import ( 10 | Batch, 11 | Component, 12 | Container, 13 | Factory, 14 | Field, 15 | File, 16 | Message, 17 | Repetition, 18 | Segment, 19 | Sequence, 20 | ) 21 | from .datatypes import parse_datetime 22 | from .exceptions import ( 23 | HL7Exception, 24 | MalformedBatchException, 25 | MalformedFileException, 26 | MalformedSegmentException, 27 | ParseException, 28 | ) 29 | from .parser import parse, parse_batch, parse_file, parse_hl7 30 | from .util import generate_message_control_id, isbatch, isfile, ishl7, split_file 31 | 32 | __version__ = "0.4.6.dev0" 33 | __author__ = "John Paulett" 34 | __email__ = "john -at- paulett.org" 35 | __license__ = "BSD" 36 | __copyright__ = "Copyright 2011, John Paulett " 37 | 38 | #: This is the HL7 Null value. It means that a field is present and blank. 39 | NULL = '""' 40 | 41 | 42 | __all__ = [ 43 | "parse", 44 | "parse_hl7", 45 | "parse_batch", 46 | "parse_file", 47 | "Sequence", 48 | "Container", 49 | "File", 50 | "Batch", 51 | "Message", 52 | "Segment", 53 | "Field", 54 | "Repetition", 55 | "Component", 56 | "Factory", 57 | "Accessor", 58 | "ishl7", 59 | "isbatch", 60 | "isfile", 61 | "split_file", 62 | "generate_message_control_id", 63 | "parse_datetime", 64 | "HL7Exception", 65 | "MalformedBatchException", 66 | "MalformedFileException", 67 | "MalformedSegmentException", 68 | "ParseException", 69 | ] 70 | -------------------------------------------------------------------------------- /hl7/accessor.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | 4 | class Accessor( 5 | namedtuple( 6 | "Accessor", 7 | [ 8 | "segment", 9 | "segment_num", 10 | "field_num", 11 | "repeat_num", 12 | "component_num", 13 | "subcomponent_num", 14 | ], 15 | ) 16 | ): 17 | __slots__ = () 18 | 19 | def __new__( 20 | cls, 21 | segment, 22 | segment_num=1, 23 | field_num=None, 24 | repeat_num=None, 25 | component_num=None, 26 | subcomponent_num=None, 27 | ): 28 | """Create a new instance of Accessor for *segment*. Index numbers start from 1.""" 29 | return super().__new__( 30 | cls, 31 | segment, 32 | segment_num, 33 | field_num, 34 | repeat_num, 35 | component_num, 36 | subcomponent_num, 37 | ) 38 | 39 | @property 40 | def key(self): 41 | """Return the string accessor key that represents this instance""" 42 | seg = ( 43 | self.segment 44 | if self.segment_num == 1 45 | else self.segment + str(self.segment_num) 46 | ) 47 | return ".".join( 48 | str(f) 49 | for f in [ 50 | seg, 51 | self.field_num, 52 | self.repeat_num, 53 | self.component_num, 54 | self.subcomponent_num, 55 | ] 56 | if f is not None 57 | ) 58 | 59 | def __str__(self): 60 | return self.key 61 | 62 | @classmethod 63 | def parse_key(cls, key): 64 | """Create an Accessor by parsing an accessor key. 65 | 66 | The key is defined as: 67 | 68 | | SEG[n]-Fn-Rn-Cn-Sn 69 | | F Field 70 | | R Repeat 71 | | C Component 72 | | S Sub-Component 73 | | 74 | | *Indexing is from 1 for compatibility with HL7 spec numbering.* 75 | 76 | Example: 77 | 78 | | PID.F1.R1.C2.S2 or PID.1.1.2.2 79 | | 80 | | PID (default to first PID segment, counting from 1) 81 | | F1 (first after segment id, HL7 Spec numbering) 82 | | R1 (repeat counting from 1) 83 | | C2 (component 2 counting from 1) 84 | | S2 (component 2 counting from 1) 85 | """ 86 | 87 | def parse_part(keyparts, index, prefix): 88 | if len(keyparts) > index: 89 | num = keyparts[index] 90 | if num[0].upper() == prefix: 91 | num = num[1:] 92 | return int(num) 93 | else: 94 | return None 95 | 96 | parts = key.split(".") 97 | segment = parts[0][:3] 98 | if len(parts[0]) > 3: 99 | segment_num = int(parts[0][3:]) 100 | else: 101 | segment_num = 1 102 | field_num = parse_part(parts, 1, "F") 103 | repeat_num = parse_part(parts, 2, "R") 104 | component_num = parse_part(parts, 3, "C") 105 | subcomponent_num = parse_part(parts, 4, "S") 106 | return cls( 107 | segment, segment_num, field_num, repeat_num, component_num, subcomponent_num 108 | ) 109 | -------------------------------------------------------------------------------- /hl7/client.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import io 3 | import os.path 4 | import socket 5 | import sys 6 | 7 | import hl7 8 | 9 | SB = b"\x0b" # , vertical tab 10 | EB = b"\x1c" # , file separator 11 | CR = b"\x0d" # , \r 12 | 13 | FF = b"\x0c" # , new page form feed 14 | 15 | RECV_BUFFER = 4096 16 | 17 | 18 | class MLLPException(Exception): 19 | pass 20 | 21 | 22 | class MLLPClient: 23 | """ 24 | A basic, blocking, HL7 MLLP client based upon :py:mod:`socket`. 25 | 26 | MLLPClient implements two methods for sending data to the server. 27 | 28 | * :py:meth:`MLLPClient.send` for raw data that already is wrapped in the 29 | appropriate MLLP container (e.g. *message*). 30 | * :py:meth:`MLLPClient.send_message` will wrap the message in the MLLP 31 | container 32 | 33 | Can be used by the ``with`` statement to ensure :py:meth:`MLLPClient.close` 34 | is called:: 35 | 36 | with MLLPClient(host, port) as client: 37 | client.send_message('MSH|...') 38 | 39 | MLLPClient takes an optional ``encoding`` parameter, defaults to UTF-8, 40 | for encoding unicode messages [#]_. 41 | 42 | .. [#] http://wiki.hl7.org/index.php?title=Character_Set_used_in_v2_messages 43 | """ 44 | 45 | def __init__(self, host, port, encoding="utf-8"): 46 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 47 | self.socket.connect((host, port)) 48 | self.encoding = encoding 49 | 50 | def __enter__(self): 51 | return self 52 | 53 | def __exit__(self, exc_type, exc_val, trackeback): 54 | self.close() 55 | 56 | def close(self): 57 | """Release the socket connection""" 58 | self.socket.close() 59 | 60 | def send_message(self, message): 61 | """Wraps a byte string, unicode string, or :py:class:`hl7.Message` 62 | in a MLLP container and send the message to the server 63 | 64 | If message is a byte string, we assume it is already encoded properly. 65 | If message is unicode or :py:class:`hl7.Message`, it will be encoded 66 | according to :py:attr:`hl7.client.MLLPClient.encoding` 67 | 68 | """ 69 | if isinstance(message, bytes): 70 | # Assume we have the correct encoding 71 | binary = message 72 | else: 73 | # Encode the unicode message into a bytestring 74 | if isinstance(message, hl7.Message): 75 | message = str(message) 76 | binary = message.encode(self.encoding) 77 | 78 | # wrap in MLLP message container 79 | data = SB + binary + EB + CR 80 | return self.send(data) 81 | 82 | def send(self, data): 83 | """Low-level, direct access to the socket.send (data must be already 84 | wrapped in an MLLP container). Blocks until the server returns. 85 | """ 86 | # upload the data 87 | self.socket.sendall(data) 88 | # wait for the ACK/NACK 89 | return self.socket.recv(RECV_BUFFER) 90 | 91 | 92 | # wrappers to make testing easier 93 | def stdout(content): 94 | # In Python 3, can't write bytes via sys.stdout.write 95 | # http://bugs.python.org/issue18512 96 | if isinstance(content, bytes): 97 | out = sys.stdout.buffer 98 | newline = b"\n" 99 | else: 100 | out = sys.stdout 101 | newline = "\n" 102 | 103 | out.write(content + newline) 104 | 105 | 106 | def stdin(): 107 | return sys.stdin 108 | 109 | 110 | def stderr(): 111 | return sys.stderr 112 | 113 | 114 | def read_stream(stream): 115 | """Buffer the stream and yield individual, stripped messages""" 116 | _buffer = b"" 117 | 118 | while True: 119 | data = stream.read(RECV_BUFFER) 120 | if data == b"": 121 | break 122 | # usually should be broken up by EB, but I have seen FF separating 123 | # messages 124 | messages = (_buffer + data).split(EB if FF not in data else FF) 125 | 126 | # whatever is in the last chunk is an uncompleted message, so put back 127 | # into the buffer 128 | _buffer = messages.pop(-1) 129 | 130 | for m in messages: 131 | yield m.strip(SB + CR) 132 | 133 | if len(_buffer.strip()) > 0: 134 | raise MLLPException("buffer not terminated: %s" % _buffer) 135 | 136 | 137 | def read_loose(stream): 138 | """Turn a HL7-like blob of text into a real HL7 messages""" 139 | # look for the START_BLOCK to delineate messages 140 | START_BLOCK = rb"MSH|^~\&|" 141 | 142 | # load all the data 143 | data = stream.read() 144 | 145 | # Take out all the typical MLLP separators. In Python 3, iterating 146 | # through a bytestring returns ints, so we need to filter out the int 147 | # versions of the separators, then convert back from a list of ints to 148 | # a bytestring. 149 | # WARNING: There is an assumption here that we can treat the data as single bytes 150 | # when filtering out the separators. 151 | separators = [bs[0] for bs in [EB, FF, SB]] 152 | data = bytes(b for b in data if b not in separators) 153 | # Windows & Unix new lines to segment separators 154 | data = data.replace(b"\r\n", b"\r").replace(b"\n", b"\r") 155 | 156 | for m in data.split(START_BLOCK): 157 | if not m: 158 | # the first element will not have any data from the split 159 | continue 160 | 161 | # strip any trailing whitespace 162 | m = m.strip(CR + b"\n ") 163 | 164 | # re-insert the START_BLOCK, which was removed via the split 165 | yield START_BLOCK + m 166 | 167 | 168 | def mllp_send(): 169 | """Command line tool to send messages to an MLLP server""" 170 | # set up the command line options 171 | script_name = os.path.basename(sys.argv[0]) 172 | parser = argparse.ArgumentParser(usage=script_name + " [options] ") 173 | 174 | parser.add_argument( 175 | "--version", 176 | action="store_true", 177 | dest="version", 178 | default=False, 179 | help="print current version and exit", 180 | ) 181 | parser.add_argument( 182 | "-p", 183 | "--port", 184 | action="store", 185 | type=int, 186 | dest="port", 187 | default=6661, 188 | help="port to connect to", 189 | ) 190 | parser.add_argument( 191 | "-f", 192 | "--file", 193 | dest="filename", 194 | help="read from FILE instead of stdin", 195 | metavar="FILE", 196 | ) 197 | parser.add_argument( 198 | "-q", 199 | "--quiet", 200 | action="store_false", 201 | dest="verbose", 202 | default=True, 203 | help="do not print status messages to stdout", 204 | ) 205 | parser.add_argument( 206 | "--loose", 207 | action="store_true", 208 | dest="loose", 209 | default=False, 210 | help=( 211 | "allow file to be a HL7-like object (\\r\\n instead " 212 | "of \\r). Requires that messages start with " 213 | '"MSH|^~\\&|". Requires --file option (no stdin)' 214 | ), 215 | ) 216 | parser.add_argument("server", nargs="?") 217 | 218 | options = parser.parse_args() 219 | 220 | if options.version: 221 | import hl7 222 | 223 | stdout(hl7.__version__) 224 | return 225 | 226 | if options.server is not None: 227 | host = options.server 228 | else: 229 | # server not present 230 | parser.print_usage() 231 | stderr().write("server required\n") 232 | sys.exit(1) 233 | return # for testing when sys.exit mocked 234 | 235 | if options.filename is not None: 236 | # Previously set stream to the open() handle, but then we did not 237 | # close the open file handle. This new approach consumes the entire 238 | # file into memory before starting to process, which is not required 239 | # or ideal, since we can handle a stream 240 | with open(options.filename, "rb") as f: 241 | stream = io.BytesIO(f.read()) 242 | else: 243 | if options.loose: 244 | stderr().write("--loose requires --file\n") 245 | sys.exit(1) 246 | return # for testing when sys.exit mocked 247 | 248 | stream = stdin() 249 | 250 | with MLLPClient(host, options.port) as client: 251 | message_stream = ( 252 | read_stream(stream) if not options.loose else read_loose(stream) 253 | ) 254 | 255 | for message in message_stream: 256 | result = client.send_message(message) 257 | if options.verbose: 258 | stdout(result) 259 | 260 | 261 | if __name__ == "__main__": 262 | mllp_send() 263 | -------------------------------------------------------------------------------- /hl7/containers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from .accessor import Accessor 5 | from .exceptions import ( 6 | MalformedBatchException, 7 | MalformedFileException, 8 | MalformedSegmentException, 9 | ) 10 | from .util import escape, generate_message_control_id, unescape 11 | 12 | logger = logging.getLogger(__file__) 13 | 14 | _SENTINEL = object() 15 | 16 | 17 | class Sequence(list): 18 | """Base class for sequences that can be indexed using 1-based index""" 19 | 20 | def __call__(self, index, value=_SENTINEL): 21 | """Support list access using HL7 compatible 1-based indices. 22 | Can be used to get and set values. 23 | 24 | >>> s = hl7.Sequence([1, 2, 3, 4]) 25 | >>> s(1) == s[0] 26 | True 27 | >>> s(2, "new") 28 | >>> s 29 | [1, 'new', 3, 4] 30 | """ 31 | index = self._adjust_index(int(index)) 32 | if value is _SENTINEL: 33 | return self[index] 34 | else: 35 | self[index] = value 36 | 37 | def _adjust_index(self, index): 38 | """Subclasses can override if they do not want HL7 1-based indexing when used as callable""" 39 | if index >= 1: 40 | return index - 1 41 | else: 42 | return index 43 | 44 | 45 | class Container(Sequence): 46 | """Abstract root class for the parts of the HL7 message.""" 47 | 48 | def __init__( 49 | self, separator, sequence=[], esc="\\", separators="\r|~^&", factory=None 50 | ): 51 | assert separator in separators 52 | # Initialize the list object, optionally passing in the 53 | # sequence. Since list([]) == [], using the default 54 | # parameter will not cause any issues. 55 | super().__init__(sequence) 56 | self.separator = separator 57 | self.esc = esc 58 | self.separators = separators 59 | self.factory = factory if factory is not None else Factory 60 | 61 | def create_file(self, seq): 62 | """Create a new :py:class:`hl7.File` compatible with this container""" 63 | return self.factory.create_file( 64 | sequence=seq, 65 | esc=self.esc, 66 | separators=self.separators, 67 | factory=self.factory, 68 | ) 69 | 70 | def create_batch(self, seq): 71 | """Create a new :py:class:`hl7.Batch` compatible with this container""" 72 | return self.factory.create_batch( 73 | sequence=seq, 74 | esc=self.esc, 75 | separators=self.separators, 76 | factory=self.factory, 77 | ) 78 | 79 | def create_message(self, seq): 80 | """Create a new :py:class:`hl7.Message` compatible with this container""" 81 | return self.factory.create_message( 82 | sequence=seq, 83 | esc=self.esc, 84 | separators=self.separators, 85 | factory=self.factory, 86 | ) 87 | 88 | def create_segment(self, seq): 89 | """Create a new :py:class:`hl7.Segment` compatible with this container""" 90 | return self.factory.create_segment( 91 | sequence=seq, 92 | esc=self.esc, 93 | separators=self.separators, 94 | factory=self.factory, 95 | ) 96 | 97 | def create_field(self, seq): 98 | """Create a new :py:class:`hl7.Field` compatible with this container""" 99 | return self.factory.create_field( 100 | sequence=seq, 101 | esc=self.esc, 102 | separators=self.separators, 103 | factory=self.factory, 104 | ) 105 | 106 | def create_repetition(self, seq): 107 | """Create a new :py:class:`hl7.Repetition` compatible with this container""" 108 | return self.factory.create_repetition( 109 | sequence=seq, 110 | esc=self.esc, 111 | separators=self.separators, 112 | factory=self.factory, 113 | ) 114 | 115 | def create_component(self, seq): 116 | """Create a new :py:class:`hl7.Component` compatible with this container""" 117 | return self.factory.create_component( 118 | sequence=seq, 119 | esc=self.esc, 120 | separators=self.separators, 121 | factory=self.factory, 122 | ) 123 | 124 | def __getitem__(self, item): 125 | # Python slice operator was returning a regular list, not a 126 | # Container subclass 127 | sequence = super().__getitem__(item) 128 | if isinstance(item, slice): 129 | return self.__class__( 130 | self.separator, 131 | sequence, 132 | self.esc, 133 | self.separators, 134 | factory=self.factory, 135 | ) 136 | return sequence 137 | 138 | def __str__(self): 139 | return self.separator.join((str(x) for x in self)) 140 | 141 | 142 | class File(Container): 143 | """Representation of an HL7 file from the batch protocol. 144 | It contains a list of :py:class:`hl7.Batch` 145 | instances. It may contain FHS/FTS :py:class:`hl7.Segment` instances. 146 | 147 | Files may or may not be wrapped in FHS/FTS segments 148 | delineating the start/end of the batch. These are optional. 149 | """ 150 | 151 | def __init__( 152 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 153 | ): 154 | assert not separator or separator == separators[0] 155 | super().__init__( 156 | separator=separators[0], 157 | sequence=sequence, 158 | esc=esc, 159 | separators=separators, 160 | factory=factory, 161 | ) 162 | self.header = None 163 | self.trailer = None 164 | 165 | @property 166 | def header(self): 167 | """FHS :py:class:`hl7.Segment`""" 168 | return self._batch_header_segment 169 | 170 | @header.setter 171 | def header(self, segment): 172 | if segment and segment[0][0] != "FHS": 173 | raise MalformedSegmentException('header must begin with "FHS"') 174 | self._batch_header_segment = segment 175 | 176 | @property 177 | def trailer(self): 178 | """FTS :py:class:`hl7.Segment`""" 179 | return self._batch_trailer_segment 180 | 181 | @trailer.setter 182 | def trailer(self, segment): 183 | if segment and segment[0][0] != "FTS": 184 | raise MalformedSegmentException('trailer must begin with "FTS"') 185 | self._batch_trailer_segment = segment 186 | 187 | def create_header(self): 188 | """Create a new :py:class:`hl7.Segment` FHS compatible with this file""" 189 | return self.create_segment( 190 | [ 191 | self.create_field(["FHS"]), 192 | self.create_field([self.separators[1]]), 193 | self.create_field( 194 | [ 195 | self.separators[3] 196 | + self.separators[2] 197 | + self.esc 198 | + self.separators[4] 199 | ] 200 | ), 201 | ] 202 | ) 203 | 204 | def create_trailer(self): 205 | """Create a new :py:class:`hl7.Segment` FTS compatible with this file""" 206 | return self.create_segment([self.create_field(["FTS"])]) 207 | 208 | def __str__(self): 209 | """Join the child batches into a single string, separated 210 | by the self.separator. This method acts recursively, calling 211 | the children's __unicode__ method. Thus ``unicode()`` is the 212 | appropriate method for turning the python-hl7 representation of 213 | HL7 into a standard string. 214 | 215 | If this batch has FHS/FTS segments, they will be added to the 216 | beginning/end of the returned string. 217 | """ 218 | if (self.header and not self.trailer) or (not self.header and self.trailer): 219 | raise MalformedFileException( 220 | "Either both header and trailer must be present or neither" 221 | ) 222 | return ( 223 | super().__str__() 224 | if not self.header 225 | else str(self.header) 226 | + self.separator 227 | + super().__str__() 228 | + str(self.trailer) 229 | + self.separator 230 | ) 231 | 232 | 233 | class Batch(Container): 234 | """Representation of an HL7 batch from the batch protocol. 235 | It contains a list of :py:class:`hl7.Message` instances. 236 | It may contain BHS/BTS :py:class:`hl7.Segment` instances. 237 | 238 | Batches may or may not be wrapped in BHS/BTS segments 239 | delineating the start/end of the batch. These are optional. 240 | """ 241 | 242 | def __init__( 243 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 244 | ): 245 | assert not separator or separator == separators[0] 246 | super().__init__( 247 | separator=separators[0], 248 | sequence=sequence, 249 | esc=esc, 250 | separators=separators, 251 | factory=factory, 252 | ) 253 | self.header = None 254 | self.trailer = None 255 | 256 | @property 257 | def header(self): 258 | """BHS :py:class:`hl7.Segment`""" 259 | return self._batch_header_segment 260 | 261 | @header.setter 262 | def header(self, segment): 263 | if segment and segment[0][0] != "BHS": 264 | raise MalformedSegmentException('header must begin with "BHS"') 265 | self._batch_header_segment = segment 266 | 267 | @property 268 | def trailer(self): 269 | """BTS :py:class:`hl7.Segment`""" 270 | return self._batch_trailer_segment 271 | 272 | @trailer.setter 273 | def trailer(self, segment): 274 | if segment and segment[0][0] != "BTS": 275 | raise MalformedSegmentException('trailer must begin with "BTS"') 276 | self._batch_trailer_segment = segment 277 | 278 | def create_header(self): 279 | """Create a new :py:class:`hl7.Segment` BHS compatible with this batch""" 280 | return self.create_segment( 281 | [ 282 | self.create_field(["BHS"]), 283 | self.create_field([self.separators[1]]), 284 | self.create_field( 285 | [ 286 | self.separators[3] 287 | + self.separators[2] 288 | + self.esc 289 | + self.separators[4] 290 | ] 291 | ), 292 | ] 293 | ) 294 | 295 | def create_trailer(self): 296 | """Create a new :py:class:`hl7.Segment` BHS compatible with this batch""" 297 | return self.create_segment([self.create_field(["BTS"])]) 298 | 299 | def __str__(self): 300 | """Join the child messages into a single string, separated 301 | by the self.separator. This method acts recursively, calling 302 | the children's __unicode__ method. Thus ``unicode()`` is the 303 | appropriate method for turning the python-hl7 representation of 304 | HL7 into a standard string. 305 | 306 | If this batch has BHS/BTS segments, they will be added to the 307 | beginning/end of the returned string. 308 | """ 309 | if (self.header and not self.trailer) or (not self.header and self.trailer): 310 | raise MalformedBatchException( 311 | "Either both header and trailer must be present or neither" 312 | ) 313 | return ( 314 | super().__str__() 315 | if not self.header 316 | else str(self.header) 317 | + self.separator 318 | + super().__str__() 319 | + str(self.trailer) 320 | + self.separator 321 | ) 322 | 323 | 324 | class Message(Container): 325 | def __init__( 326 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 327 | ): 328 | assert not separator or separator == separators[0] 329 | super().__init__( 330 | separator=separators[0], 331 | sequence=sequence, 332 | esc=esc, 333 | separators=separators, 334 | factory=factory, 335 | ) 336 | 337 | """Representation of an HL7 message. It contains a list 338 | of :py:class:`hl7.Segment` instances. 339 | """ 340 | 341 | def __getitem__(self, key): 342 | """Index, segment-based or accessor lookup. 343 | 344 | If key is an integer, ``__getitem__`` acts list a list, returning 345 | the :py:class:`hl7.Segment` held at that index: 346 | 347 | >>> h[1] # doctest: +ELLIPSIS 348 | [['PID'], ...] 349 | 350 | If the key is a string of length 3, ``__getitem__`` acts like a dictionary, 351 | returning all segments whose *segment_id* is *key* 352 | (alias of :py:meth:`hl7.Message.segments`). 353 | 354 | >>> h['OBX'] # doctest: +ELLIPSIS 355 | [[['OBX'], ['1'], ...]] 356 | 357 | If the key is a string of length greater than 3, 358 | the key is parsed into an :py:class:`hl7.Accessor` and passed 359 | to :py:meth:`hl7.Message.extract_field`. 360 | 361 | If the key is an :py:class:`hl7.Accessor`, it is passed to 362 | :py:meth:`hl7.Message.extract_field`. 363 | """ 364 | if isinstance(key, str): 365 | if len(key) == 3: 366 | return self.segments(key) 367 | return self.extract_field(*Accessor.parse_key(key)) 368 | elif isinstance(key, Accessor): 369 | return self.extract_field(*key) 370 | return super().__getitem__(key) 371 | 372 | def __setitem__(self, key, value): 373 | """Index or accessor assignment. 374 | 375 | If key is an integer, ``__setitem__`` acts list a list, setting 376 | the :py:class:`hl7.Segment` held at that index: 377 | 378 | >>> h[1] = hl7.Segment("|", [hl7.Field("~", ['PID'], [''])]) 379 | 380 | If the key is a string of length greater than 3, 381 | the key is parsed into an :py:class:`hl7.Accessor` and passed 382 | to :py:meth:`hl7.Message.assign_field`. 383 | 384 | >>> h["PID.2"] = "NEW" 385 | 386 | If the key is an :py:class:`hl7.Accessor`, it is passed to 387 | :py:meth:`hl7.Message.assign_field`. 388 | """ 389 | if isinstance(key, str) and len(key) > 3 and isinstance(value, str): 390 | return self.assign_field(value, *Accessor.parse_key(key)) 391 | elif isinstance(key, Accessor): 392 | return self.assign_field(value, *key) 393 | return super().__setitem__(key, value) 394 | 395 | def segment(self, segment_id): 396 | """Gets the first segment with the *segment_id* from the parsed 397 | *message*. 398 | 399 | >>> h.segment('PID') # doctest: +ELLIPSIS 400 | [['PID'], ...] 401 | 402 | :rtype: :py:class:`hl7.Segment` 403 | """ 404 | # Get the list of all the segments and pull out the first one, 405 | # if possible 406 | match = self.segments(segment_id) 407 | # We should never get an IndexError, since segments will instead 408 | # throw an KeyError 409 | return match[0] 410 | 411 | def segments(self, segment_id): 412 | """Returns the requested segments from the parsed *message* that are 413 | identified by the *segment_id* (e.g. OBR, MSH, ORC, OBX). 414 | 415 | >>> h.segments('OBX') 416 | [[['OBX'], ['1'], ...]] 417 | 418 | :rtype: list of :py:class:`hl7.Segment` 419 | """ 420 | # Compare segment_id to the very first string in each segment, 421 | # returning all segments that match. 422 | # Return as a Sequence so 1-based indexing can be used 423 | matches = Sequence(segment for segment in self if segment[0][0] == segment_id) 424 | if len(matches) == 0: 425 | raise KeyError("No %s segments" % segment_id) 426 | return matches 427 | 428 | def extract_field( 429 | self, 430 | segment, 431 | segment_num=1, 432 | field_num=1, 433 | repeat_num=1, 434 | component_num=1, 435 | subcomponent_num=1, 436 | ): 437 | """ 438 | Extract a field using a future proofed approach, based on rules in: 439 | http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 440 | 441 | 'PID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2', 442 | 443 | | PID.F3.R1.C2.S2 = 'Sub-Component2' 444 | | PID.F4.R2.C1 = 'Repeat1' 445 | 446 | Compatibility Rules: 447 | 448 | If the parse tree is deeper than the specified path continue 449 | following the first child branch until a leaf of the tree is 450 | encountered and return that value (which could be blank). 451 | 452 | Example: 453 | 454 | | PID.F3.R1.C2 = 'Sub-Component1' (assume .SC1) 455 | 456 | If the parse tree terminates before the full path is satisfied 457 | check each of the subsequent paths and if every one is specified 458 | at position 1 then the leaf value reached can be returned as the 459 | result. 460 | 461 | | PID.F4.R1.C1.SC1 = 'Repeat1' (ignore .SC1) 462 | """ 463 | return self.segments(segment)(segment_num).extract_field( 464 | segment_num, field_num, repeat_num, component_num, subcomponent_num 465 | ) 466 | 467 | def assign_field( 468 | self, 469 | value, 470 | segment, 471 | segment_num=1, 472 | field_num=None, 473 | repeat_num=None, 474 | component_num=None, 475 | subcomponent_num=None, 476 | ): 477 | """ 478 | Assign a value into a message using the tree based assignment notation. 479 | The segment must exist. 480 | 481 | Extract a field using a future proofed approach, based on rules in: 482 | http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 483 | """ 484 | self.segments(segment)(segment_num).assign_field( 485 | value, field_num, repeat_num, component_num, subcomponent_num 486 | ) 487 | 488 | def escape(self, field, app_map=None): 489 | """ 490 | See: http://www.hl7standards.com/blog/2006/11/02/hl7-escape-sequences/ 491 | 492 | To process this correctly, the full set of separators (MSH.1/MSH.2) needs to be known. 493 | 494 | Pass through the message. Replace recognised characters with their escaped 495 | version. Return an ascii encoded string. 496 | 497 | Functionality: 498 | 499 | * Replace separator characters (2.10.4) 500 | * replace application defined characters (2.10.7) 501 | * Replace non-ascii values with hex versions using HL7 conventions. 502 | 503 | Incomplete: 504 | 505 | * replace highlight characters (2.10.3) 506 | * How to handle the rich text substitutions. 507 | * Merge contiguous hex values 508 | """ 509 | return escape(self, field, app_map) 510 | 511 | def unescape(self, field, app_map=None): 512 | """ 513 | See: http://www.hl7standards.com/blog/2006/11/02/hl7-escape-sequences/ 514 | 515 | To process this correctly, the full set of separators (MSH.1/MSH.2) needs to be known. 516 | 517 | This will convert the identifiable sequences. 518 | If the application provides mapping, these are also used. 519 | Items which cannot be mapped are removed 520 | 521 | For example, the App Map count provide N, H, Zxxx values 522 | 523 | Chapter 2: Section 2.10 524 | 525 | At the moment, this functionality can: 526 | 527 | * replace the parsing characters (2.10.4) 528 | * replace highlight characters (2.10.3) 529 | * replace hex characters. (2.10.5) 530 | * replace rich text characters (2.10.6) 531 | * replace application defined characters (2.10.7) 532 | 533 | It cannot: 534 | 535 | * switch code pages / ISO IR character sets 536 | """ 537 | return unescape(self, field, app_map) 538 | 539 | def create_ack( 540 | self, ack_code="AA", message_id=None, application=None, facility=None 541 | ): 542 | """ 543 | Create an hl7 ACK response :py:class:`hl7.Message`, per spec 2.9.2, for this message. 544 | 545 | See http://www.hl7standards.com/blog/2007/02/01/ack-message-original-mode-acknowledgement/ 546 | 547 | ``ack_code`` options are one of `AA` (Application Accept), `AR` (Application Reject), 548 | `AE` (Application Error), `CA` (Commit Accept - Enhanced Mode), 549 | `CR` (Commit Reject - Enhanced Mode), or `CE` (Commit Error - Enhanced Mode) 550 | (see HL7 Table 0008 - Acknowledgment Code) 551 | ``message_id`` control message ID for ACK, defaults to unique generated ID 552 | ``application`` name of sending application, defaults to receiving application of message 553 | ``facility`` name of sending facility, defaults to receiving facility of message 554 | """ 555 | source_msh = self.segment("MSH") 556 | msh = self.create_segment([self.create_field(["MSH"])]) 557 | 558 | msh.assign_field(str(source_msh(1)), 1) 559 | msh.assign_field(str(source_msh(2)), 2) 560 | # Sending application is source receiving application 561 | msh.assign_field( 562 | str(application) if application is not None else str(source_msh(5)), 3 563 | ) 564 | # Sending facility is source receiving facility 565 | msh.assign_field( 566 | str(facility) if facility is not None else str(source_msh(6)), 4 567 | ) 568 | # Receiving application is source sending application 569 | msh.assign_field(str(source_msh(3)), 5) 570 | # Receiving facility is source sending facility 571 | msh.assign_field(str(source_msh(4)), 6) 572 | msh.assign_field(str(datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")), 7) 573 | # Message type code 574 | msh.assign_field("ACK", 9, 1, 1) 575 | # Copy trigger event from source 576 | msh.assign_field(str(source_msh(9)(1)(2)), 9, 1, 2) 577 | msh.assign_field("ACK", 9, 1, 3) 578 | msh.assign_field( 579 | message_id if message_id is not None else generate_message_control_id(), 10 580 | ) 581 | msh.assign_field(str(source_msh(11)), 11) 582 | msh.assign_field(str(source_msh(12)), 12) 583 | 584 | msa = self.create_segment([self.create_field(["MSA"])]) 585 | msa.assign_field(str(ack_code), 1) 586 | msa.assign_field(str(source_msh(10)), 2) 587 | ack = self.create_message([msh, msa]) 588 | 589 | return ack 590 | 591 | def __str__(self): 592 | """Join the child containers into a single string, separated 593 | by the self.separator. This method acts recursively, calling 594 | the children's __unicode__ method. Thus ``unicode()`` is the 595 | appropriate method for turning the python-hl7 representation of 596 | HL7 into a standard string. 597 | 598 | >>> str(hl7.parse(message)) == message 599 | True 600 | 601 | """ 602 | # Per spec, Message Construction Rules, Section 2.6 (v2.8), Message ends 603 | # with the carriage return 604 | return super().__str__() + self.separator 605 | 606 | 607 | class Segment(Container): 608 | def __init__( 609 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 610 | ): 611 | assert not separator or separator == separators[1] 612 | super().__init__( 613 | separator=separators[1], 614 | sequence=sequence, 615 | esc=esc, 616 | separators=separators, 617 | factory=factory, 618 | ) 619 | 620 | """Second level of an HL7 message, which represents an HL7 Segment. 621 | Traditionally this is a line of a message that ends with a carriage 622 | return and is separated by pipes. It contains a list of 623 | :py:class:`hl7.Field` instances. 624 | """ 625 | 626 | def extract_field( 627 | self, 628 | segment_num=1, 629 | field_num=1, 630 | repeat_num=1, 631 | component_num=1, 632 | subcomponent_num=1, 633 | ): 634 | """ 635 | Extract a field using a future proofed approach, based on rules in: 636 | http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 637 | 638 | 'PID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2', 639 | 640 | | F3.R1.C2.S2 = 'Sub-Component2' 641 | | F4.R2.C1 = 'Repeat1' 642 | 643 | Compatibility Rules: 644 | 645 | If the parse tree is deeper than the specified path continue 646 | following the first child branch until a leaf of the tree is 647 | encountered and return that value (which could be blank). 648 | 649 | Example: 650 | 651 | | F3.R1.C2 = 'Sub-Component1' (assume .SC1) 652 | 653 | If the parse tree terminates before the full path is satisfied 654 | check each of the subsequent paths and if every one is specified 655 | at position 1 then the leaf value reached can be returned as the 656 | result. 657 | 658 | | F4.R1.C1.SC1 = 'Repeat1' (ignore .SC1) 659 | """ 660 | # Save original values for error messages 661 | accessor = Accessor( 662 | self[0][0], 663 | segment_num, 664 | field_num, 665 | repeat_num, 666 | component_num, 667 | subcomponent_num, 668 | ) 669 | 670 | field_num = field_num or 1 671 | repeat_num = repeat_num or 1 672 | component_num = component_num or 1 673 | subcomponent_num = subcomponent_num or 1 674 | 675 | if field_num < len(self): 676 | field = self(field_num) 677 | else: 678 | if repeat_num == 1 and component_num == 1 and subcomponent_num == 1: 679 | return "" # Assume non-present optional value 680 | raise IndexError("Field not present: {0}".format(accessor.key)) 681 | 682 | rep = field(repeat_num) 683 | 684 | if not isinstance(rep, Repetition): 685 | # leaf 686 | if component_num == 1 and subcomponent_num == 1: 687 | return ( 688 | rep 689 | if accessor.segment == "MSH" and accessor.field_num in (1, 2) 690 | else unescape(self, rep) 691 | ) 692 | raise IndexError( 693 | "Field reaches leaf node before completing path: {0}".format( 694 | accessor.key 695 | ) 696 | ) 697 | 698 | if component_num > len(rep): 699 | if subcomponent_num == 1: 700 | return "" # Assume non-present optional value 701 | raise IndexError("Component not present: {0}".format(accessor.key)) 702 | 703 | component = rep(component_num) 704 | if not isinstance(component, Component): 705 | # leaf 706 | if subcomponent_num == 1: 707 | return unescape(self, component) 708 | raise IndexError( 709 | "Field reaches leaf node before completing path: {0}".format( 710 | accessor.key 711 | ) 712 | ) 713 | 714 | if subcomponent_num <= len(component): 715 | subcomponent = component(subcomponent_num) 716 | return unescape(self, subcomponent) 717 | else: 718 | return "" # Assume non-present optional value 719 | 720 | def assign_field( 721 | self, 722 | value, 723 | field_num=None, 724 | repeat_num=None, 725 | component_num=None, 726 | subcomponent_num=None, 727 | ): 728 | """ 729 | Assign a value into a message using the tree based assignment notation. 730 | The segment must exist. 731 | 732 | Extract a field using a future proofed approach, based on rules in: 733 | http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 734 | """ 735 | 736 | while len(self) <= field_num: 737 | self.append(self.create_field([])) 738 | field = self(field_num) 739 | if repeat_num is None: 740 | field[:] = [value] 741 | return 742 | while len(field) < repeat_num: 743 | field.append(self.create_repetition([])) 744 | repetition = field(repeat_num) 745 | if component_num is None: 746 | repetition[:] = [value] 747 | return 748 | while len(repetition) < component_num: 749 | repetition.append(self.create_component([])) 750 | component = repetition(component_num) 751 | if subcomponent_num is None: 752 | component[:] = [value] 753 | return 754 | while len(component) < subcomponent_num: 755 | component.append("") 756 | component(subcomponent_num, value) 757 | 758 | def _adjust_index(self, index): 759 | # First element is the segment name, so we don't need to adjust to get 1-based 760 | return index 761 | 762 | def __str__(self): 763 | if str(self[0]) in ["MSH", "FHS", "BHS"]: 764 | return ( 765 | str(self[0]) 766 | + str(self[1]) 767 | + str(self[2]) 768 | + str(self[1]) 769 | + self.separator.join((str(x) for x in self[3:])) 770 | ) 771 | return super().__str__() 772 | 773 | 774 | class Field(Container): 775 | def __init__( 776 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 777 | ): 778 | assert not separator or separator == separators[2] 779 | super().__init__( 780 | separator=separators[2], 781 | sequence=sequence, 782 | esc=esc, 783 | separators=separators, 784 | factory=factory, 785 | ) 786 | 787 | """Third level of an HL7 message, that traditionally is surrounded 788 | by pipes and separated by carets. It contains a list of strings 789 | or :py:class:`hl7.Repetition` instances. 790 | """ 791 | 792 | 793 | class Repetition(Container): 794 | def __init__( 795 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 796 | ): 797 | assert not separator or separator == separators[3] 798 | super().__init__( 799 | separator=separators[3], 800 | sequence=sequence, 801 | esc=esc, 802 | separators=separators, 803 | factory=factory, 804 | ) 805 | 806 | """Fourth level of an HL7 message. A field can repeat. 807 | It contains a list of strings or :py:class:`hl7.Component` instances. 808 | """ 809 | 810 | 811 | class Component(Container): 812 | def __init__( 813 | self, separator=None, sequence=[], esc="\\", separators="\r|~^&", factory=None 814 | ): 815 | assert not separator or separator == separators[4] 816 | super().__init__( 817 | separator=separators[4], 818 | sequence=sequence, 819 | esc=esc, 820 | separators=separators, 821 | factory=factory, 822 | ) 823 | 824 | """Fifth level of an HL7 message. A component is a composite datatypes. 825 | It contains a list of string sub-components. 826 | """ 827 | 828 | 829 | class Factory: 830 | """Factory used to create each type of Container. 831 | 832 | A subclass can be used to create specialized subclasses of each container. 833 | """ 834 | 835 | create_file = File #: Create an instance of :py:class:`hl7.File` 836 | create_batch = Batch #: Create an instance of :py:class:`hl7.Batch` 837 | create_message = Message #: Create an instance of :py:class:`hl7.Message` 838 | create_segment = Segment #: Create an instance of :py:class:`hl7.Segment` 839 | create_field = Field #: Create an instance of :py:class:`hl7.Field` 840 | create_repetition = Repetition #: Create an instance of :py:class:`hl7.Repetition` 841 | create_component = Component #: Create an instance of :py:class:`hl7.Component` 842 | -------------------------------------------------------------------------------- /hl7/datatypes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import re 4 | 5 | DTM_TZ_RE = re.compile(r"(\d+(?:\.\d+)?)(?:([+-]\d{2})(\d{2}))?") 6 | 7 | 8 | class _UTCOffset(datetime.tzinfo): 9 | """Fixed offset timezone from UTC.""" 10 | 11 | def __init__(self, minutes): 12 | """``minutes`` is an offset from UTC, negative for west of UTC.""" 13 | # ``minutes`` may be passed in as a float when constructed via 14 | # :func:`parse_datetime`. ``datetime.timedelta`` and formatting of the 15 | # timezone name expect an ``int``. Store the offset as an ``int`` to 16 | # avoid producing values like ``-5.00.0`` from ``tzname`` when floats are 17 | # used. 18 | self.minutes = int(minutes) 19 | 20 | def utcoffset(self, dt): 21 | return datetime.timedelta(minutes=self.minutes) 22 | 23 | def tzname(self, dt): 24 | minutes = abs(self.minutes) 25 | return "{0}{1:02}{2:02}".format( 26 | "-" if self.minutes < 0 else "+", minutes // 60, minutes % 60 27 | ) 28 | 29 | def dst(self, dt): 30 | return datetime.timedelta(0) 31 | 32 | 33 | def parse_datetime(value): 34 | """Parse hl7 DTM string ``value`` :py:class:`datetime.datetime`. 35 | 36 | ``value`` is of the format YYYY[MM[DD[HH[MM[SS[.S[S[S[S]]]]]]]]][+/-HHMM] 37 | or a ValueError will be raised. 38 | 39 | :rtype: :py:;class:`datetime.datetime` 40 | """ 41 | if not value: 42 | return None 43 | 44 | # Split off optional timezone 45 | dt_match = DTM_TZ_RE.match(value) 46 | if not dt_match: 47 | raise ValueError("Malformed HL7 datetime {0}".format(value)) 48 | dtm = dt_match.group(1) 49 | tzh = dt_match.group(2) 50 | tzm = dt_match.group(3) 51 | if tzh and tzm: 52 | minutes = int(tzh) * 60 53 | minutes += math.copysign(int(tzm), minutes) 54 | tzinfo = _UTCOffset(minutes) 55 | else: 56 | tzinfo = None 57 | 58 | precision = len(dtm) 59 | 60 | if precision >= 4: 61 | year = int(dtm[0:4]) 62 | else: 63 | raise ValueError("Malformed HL7 datetime {0}".format(value)) 64 | 65 | if precision >= 6: 66 | month = int(dtm[4:6]) 67 | else: 68 | month = 1 69 | 70 | if precision >= 8: 71 | day = int(dtm[6:8]) 72 | else: 73 | day = 1 74 | 75 | if precision >= 10: 76 | hour = int(dtm[8:10]) 77 | else: 78 | hour = 0 79 | 80 | if precision >= 12: 81 | minute = int(dtm[10:12]) 82 | else: 83 | minute = 0 84 | 85 | if precision >= 14: 86 | delta = datetime.timedelta(seconds=float(dtm[12:])) 87 | second = delta.seconds 88 | microsecond = delta.microseconds 89 | else: 90 | second = 0 91 | microsecond = 0 92 | 93 | return datetime.datetime( 94 | year, month, day, hour, minute, second, microsecond, tzinfo=tzinfo 95 | ) 96 | -------------------------------------------------------------------------------- /hl7/exceptions.py: -------------------------------------------------------------------------------- 1 | class HL7Exception(Exception): 2 | pass 3 | 4 | 5 | class MalformedSegmentException(HL7Exception): 6 | pass 7 | 8 | 9 | class MalformedBatchException(HL7Exception): 10 | pass 11 | 12 | 13 | class MalformedFileException(HL7Exception): 14 | pass 15 | 16 | 17 | class ParseException(HL7Exception): 18 | pass 19 | -------------------------------------------------------------------------------- /hl7/mllp/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import InvalidBlockError 2 | from .streams import ( 3 | HL7StreamProtocol, 4 | HL7StreamReader, 5 | HL7StreamWriter, 6 | MLLPStreamReader, 7 | MLLPStreamWriter, 8 | open_hl7_connection, 9 | start_hl7_server, 10 | ) 11 | 12 | __all__ = [ 13 | "open_hl7_connection", 14 | "start_hl7_server", 15 | "HL7StreamProtocol", 16 | "HL7StreamReader", 17 | "HL7StreamWriter", 18 | "MLLPStreamReader", 19 | "MLLPStreamWriter", 20 | "InvalidBlockError", 21 | ] 22 | -------------------------------------------------------------------------------- /hl7/mllp/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidBlockError(Exception): 2 | """An MLLP Block was received that violates MLLP protocol""" 3 | -------------------------------------------------------------------------------- /hl7/mllp/streams.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from asyncio import ( 3 | LimitOverrunError, 4 | StreamReader, 5 | StreamReaderProtocol, 6 | StreamWriter, 7 | get_event_loop, 8 | iscoroutine, 9 | ) 10 | from asyncio.streams import _DEFAULT_LIMIT 11 | 12 | from hl7.mllp.exceptions import InvalidBlockError 13 | from hl7.parser import parse as hl7_parse 14 | 15 | START_BLOCK = b"\x0b" 16 | END_BLOCK = b"\x1c" 17 | CARRIAGE_RETURN = b"\x0d" 18 | 19 | 20 | async def open_hl7_connection( 21 | host=None, 22 | port=None, 23 | *, 24 | loop=None, 25 | limit=_DEFAULT_LIMIT, 26 | encoding=None, 27 | encoding_errors=None, 28 | **kwds, 29 | ): 30 | """A wrapper for `loop.create_connection()` returning a (reader, writer) pair. 31 | 32 | The reader returned is a :py:class:`hl7.mllp.HL7StreamReader` instance; the writer is a 33 | :py:class:`hl7.mllp.HL7StreamWriter` instance. 34 | 35 | The arguments are all the usual arguments to create_connection() 36 | except `protocol_factory`; most common are positional `host` and `port`, 37 | with various optional keyword arguments following. 38 | 39 | Additional optional keyword arguments are `loop` (to set the event loop 40 | instance to use), `limit` (to set the buffer limit passed to the 41 | :py:class:`hl7.mllp.HL7StreamReader`), `encoding` (to set the encoding on the :py:class:`hl7.mllp.HL7StreamReader` 42 | and :py:class:`hl7.mllp.HL7StreamWriter`) and `encoding_errors` (to set the encoding_errors on the :py:class:`hl7.mllp.HL7StreamReader` 43 | and :py:class:`hl7.mllp.HL7StreamWriter`). 44 | """ 45 | if loop is None: 46 | loop = get_event_loop() 47 | else: 48 | warnings.warn( 49 | "The loop argument is deprecated since Python 3.8, " 50 | "and scheduled for removal in Python 3.10.", 51 | DeprecationWarning, 52 | stacklevel=2, 53 | ) 54 | reader = HL7StreamReader( 55 | limit=limit, loop=loop, encoding=encoding, encoding_errors=encoding_errors 56 | ) 57 | protocol = HL7StreamProtocol( 58 | reader, loop=loop, encoding=encoding, encoding_errors=encoding_errors 59 | ) 60 | transport, _ = await loop.create_connection(lambda: protocol, host, port, **kwds) 61 | writer = HL7StreamWriter( 62 | transport, protocol, reader, loop, encoding, encoding_errors 63 | ) 64 | return reader, writer 65 | 66 | 67 | async def start_hl7_server( 68 | client_connected_cb, 69 | host=None, 70 | port=None, 71 | *, 72 | loop=None, 73 | limit=_DEFAULT_LIMIT, 74 | encoding=None, 75 | encoding_errors=None, 76 | **kwds, 77 | ): 78 | """Start a socket server, call back for each client connected. 79 | 80 | The first parameter, `client_connected_cb`, takes two parameters: 81 | `client_reader`, `client_writer`. `client_reader` is a 82 | :py:class:`hl7.mllp.HL7StreamReader` object, while `client_writer` 83 | is a :py:class:`hl7.mllp.HL7StreamWriter` object. This 84 | parameter can either be a plain callback function or a coroutine; 85 | if it is a coroutine, it will be automatically converted into a 86 | `Task`. 87 | 88 | The rest of the arguments are all the usual arguments to 89 | `loop.create_server()` except `protocol_factory`; most common are 90 | positional `host` and `port`, with various optional keyword arguments 91 | following. 92 | 93 | The return value is the same as `loop.create_server()`. 94 | Additional optional keyword arguments are `loop` (to set the event loop 95 | instance to use) and `limit` (to set the buffer limit passed to the 96 | StreamReader). 97 | 98 | The return value is the same as `loop.create_server()`, i.e. a 99 | `Server` object which can be used to stop the service. 100 | """ 101 | if loop is None: 102 | loop = get_event_loop() 103 | else: 104 | warnings.warn( 105 | "The loop argument is deprecated since Python 3.8, " 106 | "and scheduled for removal in Python 3.10.", 107 | DeprecationWarning, 108 | stacklevel=2, 109 | ) 110 | 111 | def factory(): 112 | reader = HL7StreamReader( 113 | limit=limit, loop=loop, encoding=encoding, encoding_errors=encoding_errors 114 | ) 115 | protocol = HL7StreamProtocol( 116 | reader, 117 | client_connected_cb, 118 | loop=loop, 119 | encoding=encoding, 120 | encoding_errors=encoding_errors, 121 | ) 122 | return protocol 123 | 124 | return await loop.create_server(factory, host, port, **kwds) 125 | 126 | 127 | class MLLPStreamReader(StreamReader): 128 | def __init__(self, limit=_DEFAULT_LIMIT, loop=None): 129 | super().__init__(limit, loop) 130 | 131 | async def readblock(self): 132 | """Read a chunk of data from the stream until the block termination 133 | separator (b'\x1c\x0d') are found. 134 | 135 | On success, the data and separator will be removed from the 136 | internal buffer (consumed). Returned data will not include the 137 | separator at the end or the MLLP start block character (b'\x0b') at the 138 | beginning. 139 | 140 | Configured stream limit is used to check result. Limit sets the 141 | maximal length of data that can be returned, not counting the 142 | separator. 143 | 144 | If an EOF occurs and the complete separator is still not found, 145 | an IncompleteReadError exception will be raised, and the internal 146 | buffer will be reset. The IncompleteReadError.partial attribute 147 | may contain the separator partially. 148 | 149 | If limit is reached, ValueError will be raised. In that case, if 150 | block termination separator was found, complete line including separator 151 | will be removed from internal buffer. Else, internal buffer will be cleared. Limit is 152 | compared against part of the line without separator. 153 | 154 | If the block is invalid (missing required start block character) and InvalidBlockError 155 | will be raised. 156 | 157 | If stream was paused, this function will automatically resume it if 158 | needed. 159 | """ 160 | sep = END_BLOCK + CARRIAGE_RETURN 161 | seplen = len(sep) 162 | try: 163 | block = await self.readuntil(sep) 164 | except LimitOverrunError as loe: 165 | if self._buffer.startswith(sep, loe.consumed): 166 | del self._buffer[: loe.consumed + seplen] 167 | else: 168 | self._buffer.clear() 169 | self._maybe_resume_transport() 170 | raise ValueError(loe.args[0]) 171 | if not block or block[0:1] != START_BLOCK: 172 | raise InvalidBlockError( 173 | "Block does not begin with Start Block character " 174 | ) 175 | return block[1:-2] 176 | 177 | 178 | class MLLPStreamWriter(StreamWriter): 179 | def __init__(self, transport, protocol, reader, loop): 180 | super().__init__(transport, protocol, reader, loop) 181 | 182 | def writeblock(self, data): 183 | """Write a block of data to the stream, 184 | encapsulating the block with b'\x0b' at the beginning 185 | and b'\x1c\x0d' at the end. 186 | """ 187 | self.write(START_BLOCK + data + END_BLOCK + CARRIAGE_RETURN) 188 | 189 | 190 | class HL7StreamProtocol(StreamReaderProtocol): 191 | def __init__( 192 | self, 193 | stream_reader, 194 | client_connected_cb=None, 195 | loop=None, 196 | encoding=None, 197 | encoding_errors=None, 198 | ): 199 | super().__init__(stream_reader, client_connected_cb, loop) 200 | self._encoding = encoding 201 | self._encoding_errors = encoding_errors 202 | 203 | def connection_made(self, transport): 204 | # _reject_connection not added until 3.8 205 | if getattr(self, "_reject_connection", False): 206 | context = { 207 | "message": ( 208 | "An open stream was garbage collected prior to " 209 | "establishing network connection; " 210 | 'call "stream.close()" explicitly.' 211 | ) 212 | } 213 | if self._source_traceback: 214 | context["source_traceback"] = self._source_traceback 215 | self._loop.call_exception_handler(context) 216 | transport.abort() 217 | return 218 | self._transport = transport 219 | reader = self._stream_reader 220 | if reader is not None: 221 | reader.set_transport(transport) 222 | self._over_ssl = transport.get_extra_info("sslcontext") is not None 223 | if self._client_connected_cb is not None: 224 | self._stream_writer = HL7StreamWriter( 225 | transport, 226 | self, 227 | reader, 228 | self._loop, 229 | self._encoding, 230 | self._encoding_errors, 231 | ) 232 | res = self._client_connected_cb(reader, self._stream_writer) 233 | if iscoroutine(res): 234 | self._loop.create_task(res) 235 | self._strong_reader = None 236 | 237 | 238 | class HL7StreamReader(MLLPStreamReader): 239 | def __init__( 240 | self, limit=_DEFAULT_LIMIT, loop=None, encoding=None, encoding_errors=None 241 | ): 242 | super().__init__(limit=limit, loop=loop) 243 | self.encoding = encoding 244 | self.encoding_errors = encoding_errors 245 | 246 | @property 247 | def encoding(self): 248 | return self._encoding 249 | 250 | @encoding.setter 251 | def encoding(self, encoding): 252 | if encoding and not isinstance(encoding, str): 253 | raise TypeError("encoding must be a str or None") 254 | self._encoding = encoding or "ascii" 255 | 256 | @property 257 | def encoding_errors(self): 258 | return self._encoding_errors 259 | 260 | @encoding_errors.setter 261 | def encoding_errors(self, encoding_errors): 262 | if encoding_errors and not isinstance(encoding_errors, str): 263 | raise TypeError("encoding_errors must be a str or None") 264 | self._encoding_errors = encoding_errors or "strict" 265 | 266 | async def readmessage(self): 267 | """Reads a full HL7 message from the stream. 268 | 269 | This will return an :py:class:`hl7.Message`. 270 | 271 | If `limit` is reached, `ValueError` will be raised. In that case, if 272 | block termination separator was found, complete line including separator 273 | will be removed from internal buffer. Else, internal buffer will be cleared. Limit is 274 | compared against part of the line without separator. 275 | 276 | If an invalid MLLP block is encountered, :py:class:`hl7.mllp.InvalidBlockError` will be 277 | raised. 278 | """ 279 | block = await self.readblock() 280 | return hl7_parse(block.decode(self.encoding, self.encoding_errors)) 281 | 282 | 283 | class HL7StreamWriter(MLLPStreamWriter): 284 | def __init__( 285 | self, transport, protocol, reader, loop, encoding=None, encoding_errors=None 286 | ): 287 | super().__init__(transport, protocol, reader, loop) 288 | self.encoding = encoding 289 | self.encoding_errors = encoding_errors 290 | 291 | @property 292 | def encoding(self): 293 | return self._encoding 294 | 295 | @encoding.setter 296 | def encoding(self, encoding): 297 | if encoding and not isinstance(encoding, str): 298 | raise TypeError("encoding must be a str or None") 299 | self._encoding = encoding or "ascii" 300 | 301 | @property 302 | def encoding_errors(self): 303 | return self._encoding_errors 304 | 305 | @encoding_errors.setter 306 | def encoding_errors(self, encoding_errors): 307 | if encoding_errors and not isinstance(encoding_errors, str): 308 | raise TypeError("encoding_errors must be a str or None") 309 | self._encoding_errors = encoding_errors or "strict" 310 | 311 | def writemessage(self, message): 312 | """Writes an :py:class:`hl7.Message` to the stream.""" 313 | self.writeblock(str(message).encode(self.encoding, self.encoding_errors)) 314 | -------------------------------------------------------------------------------- /hl7/parser.py: -------------------------------------------------------------------------------- 1 | from string import whitespace 2 | 3 | from .containers import Factory 4 | from .exceptions import ParseException 5 | from .util import isbatch, isfile, ishl7 6 | 7 | _HL7_WHITESPACE = whitespace.replace("\r", "") 8 | 9 | 10 | def parse_hl7(line, encoding="utf-8", factory=Factory): 11 | """Returns a instance of the :py:class:`hl7.Message`, :py:class:`hl7.Batch` 12 | or :py:class:`hl7.File` that allows indexed access to the data elements or 13 | messages or batches respectively. 14 | 15 | A custom :py:class:`hl7.Factory` subclass can be passed in to be used when 16 | constructing the message/batch/file and its components. 17 | 18 | .. note:: 19 | 20 | HL7 usually contains only ASCII, but can use other character 21 | sets (HL7 Standards Document, Section 1.7.1), however as of v2.8, 22 | UTF-8 is the preferred character set [#]_. 23 | 24 | python-hl7 works on Python unicode strings. :py:func:`hl7.parse_hl7` 25 | will accept unicode string or will attempt to convert bytestrings 26 | into unicode strings using the optional ``encoding`` parameter. 27 | ``encoding`` defaults to UTF-8, so no work is needed for bytestrings 28 | in UTF-8, but for other character sets like 'cp1252' or 'latin1', 29 | ``encoding`` must be set appropriately. 30 | 31 | >>> h = hl7.parse_hl7(message) 32 | 33 | To decode a non-UTF-8 byte string:: 34 | 35 | hl7.parse_hl7(message, encoding='latin1') 36 | 37 | :rtype: :py:class:`hl7.Message` | :py:class:`hl7.Batch` | :py:class:`hl7.File` 38 | 39 | .. [#] http://wiki.hl7.org/index.php?title=Character_Set_used_in_v2_messages 40 | 41 | """ 42 | # Ensure we are working with unicode data, decode the bytestring 43 | # if needed 44 | if isinstance(line, bytes): 45 | line = line.decode(encoding) 46 | # If it is an HL7 message, parse as normal 47 | if ishl7(line): 48 | return parse(line, encoding=encoding, factory=factory) 49 | # If we have a batch, then parse the batch 50 | elif isbatch(line): 51 | return parse_batch(line, encoding=encoding, factory=factory) 52 | # If we have a file, parse the HL7 file 53 | elif isfile(line): 54 | return parse_file(line, encoding=encoding, factory=factory) 55 | # Not an HL7 message 56 | raise ValueError("line is not HL7") 57 | 58 | 59 | def parse(lines, encoding="utf-8", factory=Factory): 60 | """Returns a instance of the :py:class:`hl7.Message` that allows 61 | indexed access to the data elements. 62 | 63 | A custom :py:class:`hl7.Factory` subclass can be passed in to be used when 64 | constructing the message and its components. 65 | 66 | .. note:: 67 | 68 | HL7 usually contains only ASCII, but can use other character 69 | sets (HL7 Standards Document, Section 1.7.1), however as of v2.8, 70 | UTF-8 is the preferred character set [#]_. 71 | 72 | python-hl7 works on Python unicode strings. :py:func:`hl7.parse` 73 | will accept unicode string or will attempt to convert bytestrings 74 | into unicode strings using the optional ``encoding`` parameter. 75 | ``encoding`` defaults to UTF-8, so no work is needed for bytestrings 76 | in UTF-8, but for other character sets like 'cp1252' or 'latin1', 77 | ``encoding`` must be set appropriately. 78 | 79 | >>> h = hl7.parse(message) 80 | 81 | To decode a non-UTF-8 byte string:: 82 | 83 | hl7.parse(message, encoding='latin1') 84 | 85 | :rtype: :py:class:`hl7.Message` 86 | 87 | .. [#] http://wiki.hl7.org/index.php?title=Character_Set_used_in_v2_messages 88 | 89 | """ 90 | # Ensure we are working with unicode data, decode the bytestring 91 | # if needed 92 | if isinstance(lines, bytes): 93 | lines = lines.decode(encoding) 94 | # Strip out unnecessary whitespace 95 | strmsg = lines.strip() 96 | # The method for parsing the message 97 | plan = create_parse_plan(strmsg, factory) 98 | # Start splitting the methods based upon the ParsePlan 99 | return _split(strmsg, plan) 100 | 101 | 102 | def _create_batch(batch, messages, encoding, factory): 103 | """Creates a :py:class:`hl7.Batch`""" 104 | kwargs = { 105 | "sequence": [ 106 | parse(message, encoding=encoding, factory=factory) for message in messages 107 | ], 108 | } 109 | # If the BHS/BTS were present, use those to set up the batch 110 | # otherwise default 111 | if batch: 112 | batch = parse(batch, encoding=encoding, factory=factory) 113 | kwargs["esc"] = batch.esc 114 | kwargs["separators"] = batch.separators 115 | kwargs["factory"] = batch.factory 116 | parsed = factory.create_batch(**kwargs) 117 | # If the BHS/BTS were present then set them 118 | if batch: 119 | parsed.header = batch.segment("BHS") 120 | try: 121 | parsed.trailer = batch.segment("BTS") 122 | except KeyError: 123 | parsed.trailer = parsed.create_segment([parsed.create_field(["BTS"])]) 124 | return parsed 125 | 126 | 127 | def parse_batch(lines, encoding="utf-8", factory=Factory): 128 | """Returns a instance of a :py:class:`hl7.Batch` 129 | that allows indexed access to the messages. 130 | 131 | A custom :py:class:`hl7.Factory` subclass can be passed in to be used when 132 | constructing the batch and its components. 133 | 134 | .. note:: 135 | 136 | HL7 usually contains only ASCII, but can use other character 137 | sets (HL7 Standards Document, Section 1.7.1), however as of v2.8, 138 | UTF-8 is the preferred character set [#]_. 139 | 140 | python-hl7 works on Python unicode strings. :py:func:`hl7.parse_batch` 141 | will accept unicode string or will attempt to convert bytestrings 142 | into unicode strings using the optional ``encoding`` parameter. 143 | ``encoding`` defaults to UTF-8, so no work is needed for bytestrings 144 | in UTF-8, but for other character sets like 'cp1252' or 'latin1', 145 | ``encoding`` must be set appropriately. 146 | 147 | >>> h = hl7.parse_batch(message) 148 | 149 | To decode a non-UTF-8 byte string:: 150 | 151 | hl7.parse_batch(message, encoding='latin1') 152 | 153 | :rtype: :py:class:`hl7.Batch` 154 | 155 | .. [#] http://wiki.hl7.org/index.php?title=Character_Set_used_in_v2_messages 156 | 157 | """ 158 | # Ensure we are working with unicode data, decode the bytestring 159 | # if needed 160 | if isinstance(lines, bytes): 161 | lines = lines.decode(encoding) 162 | batch = None 163 | messages = [] 164 | # Split the batch into lines, retaining the ends 165 | for line in lines.strip(_HL7_WHITESPACE).splitlines(keepends=True): 166 | # strip out all whitespace MINUS the '\r' 167 | line = line.strip(_HL7_WHITESPACE) 168 | if line[:3] == "BHS": 169 | if batch: 170 | raise ParseException("Batch cannot have more than one BHS segment") 171 | batch = line 172 | elif line[:3] == "BTS": 173 | if not batch or "\rBTS" in batch: 174 | continue 175 | batch += line 176 | elif line[:3] == "MSH": 177 | messages.append(line) 178 | else: 179 | if not messages: 180 | raise ParseException( 181 | "Segment received before message header {}".format(line) 182 | ) 183 | messages[-1] += line 184 | return _create_batch(batch, messages, encoding, factory) 185 | 186 | 187 | def _create_file(file, batches, encoding, factory): 188 | kwargs = { 189 | "sequence": [ 190 | _create_batch(batch[0], batch[1], encoding, factory) for batch in batches 191 | ], 192 | } 193 | # If the FHS/FTS are present, use them to set up the file 194 | if file: 195 | file = parse(file, encoding=encoding, factory=factory) 196 | kwargs["esc"] = file.esc 197 | kwargs["separators"] = file.separators 198 | kwargs["factory"] = file.factory 199 | parsed = factory.create_file(**kwargs) 200 | # If the FHS/FTS are present, add them 201 | if file: 202 | parsed.header = file.segment("FHS") 203 | try: 204 | parsed.trailer = file.segment("FTS") 205 | except KeyError: 206 | parsed.trailer = parsed.create_segment([parsed.create_field(["FTS"])]) 207 | return parsed 208 | 209 | 210 | def parse_file(lines, encoding="utf-8", factory=Factory): # noqa: C901 211 | """Returns a instance of the :py:class:`hl7.File` that allows 212 | indexed access to the batches. 213 | 214 | A custom :py:class:`hl7.Factory` subclass can be passed in to be used when 215 | constructing the file and its components. 216 | 217 | .. note:: 218 | 219 | HL7 usually contains only ASCII, but can use other character 220 | sets (HL7 Standards Document, Section 1.7.1), however as of v2.8, 221 | UTF-8 is the preferred character set [#]_. 222 | 223 | python-hl7 works on Python unicode strings. :py:func:`hl7.parse_file` 224 | will accept unicode string or will attempt to convert bytestrings 225 | into unicode strings using the optional ``encoding`` parameter. 226 | ``encoding`` defaults to UTF-8, so no work is needed for bytestrings 227 | in UTF-8, but for other character sets like 'cp1252' or 'latin1', 228 | ``encoding`` must be set appropriately. 229 | 230 | >>> h = hl7.parse_file(message) 231 | 232 | To decode a non-UTF-8 byte string:: 233 | 234 | hl7.parse_file(message, encoding='latin1') 235 | 236 | :rtype: :py:class:`hl7.File` 237 | 238 | .. [#] http://wiki.hl7.org/index.php?title=Character_Set_used_in_v2_messages 239 | 240 | """ 241 | # Ensure we are working with unicode data, decode the bytestring 242 | # if needed 243 | if isinstance(lines, bytes): 244 | lines = lines.decode(encoding) 245 | file = None 246 | batches = [] 247 | messages = [] 248 | in_batch = False 249 | # Split the file into lines, retaining the ends 250 | for line in lines.strip(_HL7_WHITESPACE).splitlines(keepends=True): 251 | # strip out all whitespace MINUS the '\r' 252 | line = line.strip(_HL7_WHITESPACE) 253 | if line[:3] == "FHS": 254 | if file: 255 | raise ParseException("File cannot have more than one FHS segment") 256 | file = line 257 | elif line[:3] == "FTS": 258 | if not file or "\rFTS" in file: 259 | continue 260 | file += line 261 | elif line[:3] == "BHS": 262 | if in_batch: 263 | raise ParseException("Batch cannot have more than one BHS segment") 264 | batches.append([line, []]) 265 | in_batch = True 266 | elif line[:3] == "BTS": 267 | if not in_batch: 268 | continue 269 | batches[-1][0] += line 270 | in_batch = False 271 | elif line[:3] == "MSH": 272 | if in_batch: 273 | batches[-1][1].append(line) 274 | else: # Messages outside of a batch go into the "default" batch 275 | messages.append(line) 276 | else: 277 | if in_batch: 278 | if not batches[-1][1]: 279 | raise ParseException( 280 | "Segment received before message header {}".format(line) 281 | ) 282 | batches[-1][1][-1] += line 283 | else: 284 | if not messages: 285 | raise ParseException( 286 | "Segment received before message header {}".format(line) 287 | ) 288 | messages[-1] += line 289 | if messages: # add the default batch, if we have one 290 | batches.append([None, messages]) 291 | return _create_file(file, batches, encoding, factory) 292 | 293 | 294 | def _split(text, plan): 295 | """Recursive function to split the *text* into an n-deep list, 296 | according to the :py:class:`hl7._ParsePlan`. 297 | """ 298 | # Base condition, if we have used up all the plans 299 | if not plan: 300 | return text 301 | 302 | if not plan.applies(text): 303 | return plan.container([text]) 304 | 305 | # Parsing of the first segment is awkward because it contains 306 | # the separator characters in a field 307 | if plan.containers[0] == plan.factory.create_segment and text[:3] in [ 308 | "MSH", 309 | "BHS", 310 | "FHS", 311 | ]: 312 | seg = text[:3] 313 | sep0 = text[3] 314 | sep_end_off = text.find(sep0, 4) 315 | seps = text[4:sep_end_off] 316 | text = text[sep_end_off + 1 :] 317 | data = [ 318 | plan.factory.create_field( 319 | sequence=[seg], esc=plan.esc, separators=plan.separators 320 | ), 321 | plan.factory.create_field( 322 | sequence=[sep0], esc=plan.esc, separators=plan.separators 323 | ), 324 | plan.factory.create_field( 325 | sequence=[seps], esc=plan.esc, separators=plan.separators 326 | ), 327 | ] 328 | else: 329 | data = [] 330 | 331 | if text: 332 | data = data + [_split(x, plan.next()) for x in text.split(plan.separator)] 333 | # Return the instance of the current message part according 334 | # to the plan 335 | return plan.container(data) 336 | 337 | 338 | def create_parse_plan(strmsg, factory=Factory): 339 | """Creates a plan on how to parse the HL7 message according to 340 | the details stored within the message. 341 | """ 342 | # We will always use a carriage return to separate segments 343 | separators = "\r" 344 | 345 | # Extract the rest of the separators. Defaults used if not present. 346 | if strmsg[:3] not in ("MSH", "FHS", "BHS"): 347 | raise ParseException( 348 | "First segment is {}, must be one of MSH, FHS or BHS".format(strmsg[:3]) 349 | ) 350 | sep0 = strmsg[3] 351 | seps = list(strmsg[3 : strmsg.find(sep0, 4)]) 352 | 353 | separators += seps[0] 354 | if len(seps) > 2: 355 | separators += seps[2] # repetition separator 356 | else: 357 | separators += "~" # repetition separator 358 | if len(seps) > 1: 359 | separators += seps[1] # component separator 360 | else: 361 | separators += "^" # component separator 362 | if len(seps) > 4: 363 | separators += seps[4] # sub-component separator 364 | else: 365 | separators += "&" # sub-component separator 366 | if len(seps) > 3: 367 | esc = seps[3] 368 | else: 369 | esc = "\\" 370 | 371 | # The ordered list of containers to create 372 | containers = [ 373 | factory.create_message, 374 | factory.create_segment, 375 | factory.create_field, 376 | factory.create_repetition, 377 | factory.create_component, 378 | ] 379 | return _ParsePlan(separators[0], separators, containers, esc, factory) 380 | 381 | 382 | class _ParsePlan: 383 | """Details on how to parse an HL7 message. Typically this object 384 | should be created via :func:`hl7.create_parse_plan` 385 | """ 386 | 387 | # field, component, repetition, escape, subcomponent 388 | 389 | def __init__(self, separator, separators, containers, esc, factory): 390 | # TODO test to see performance implications of the assertion 391 | # since we generate the ParsePlan, this should never be in 392 | # invalid state 393 | assert len(containers) == len(separators[separators.find(separator) :]) 394 | self.separator = separator 395 | self.separators = separators 396 | self.containers = containers 397 | self.esc = esc 398 | self.factory = factory 399 | 400 | def container(self, data): 401 | """Return an instance of the appropriate container for the *data* 402 | as specified by the current plan. 403 | """ 404 | return self.containers[0]( 405 | sequence=data, 406 | esc=self.esc, 407 | separators=self.separators, 408 | factory=self.factory, 409 | ) 410 | 411 | def next(self): 412 | """Generate the next level of the plan (essentially generates 413 | a copy of this plan with the level of the container and the 414 | separator starting at the next index. 415 | """ 416 | if len(self.containers) > 1: 417 | # Return a new instance of this class using the tails of 418 | # the separators and containers lists. Use self.__class__() 419 | # in case :class:`hl7.ParsePlan` is subclassed 420 | return self.__class__( 421 | self.separators[self.separators.find(self.separator) + 1], 422 | self.separators, 423 | self.containers[1:], 424 | self.esc, 425 | self.factory, 426 | ) 427 | # When we have no separators and containers left, return None, 428 | # which indicates that we have nothing further. 429 | return None 430 | 431 | def applies(self, text): 432 | """return True if the separator or those if the children are in the text""" 433 | for s in self.separators[self.separators.find(self.separator) :]: 434 | if text.find(s) >= 0: 435 | return True 436 | return False 437 | -------------------------------------------------------------------------------- /hl7/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import random 4 | import string 5 | 6 | logger = logging.getLogger(__file__) 7 | 8 | 9 | def ishl7(line): 10 | """Determines whether a *line* looks like an HL7 message. 11 | This method only does a cursory check and does not fully 12 | validate the message. 13 | 14 | :rtype: bool 15 | """ 16 | # Prevent issues if the line is empty 17 | if not line: 18 | return False 19 | msh = line.strip()[:4] 20 | if len(msh) != 4: 21 | return False 22 | return msh[:3] == "MSH" and line.count("\rMSH" + msh[3]) == 0 23 | 24 | 25 | def isbatch(line): 26 | """ 27 | Batches are wrapped in BHS / BTS or have more than one 28 | message 29 | BHS = batch header segment 30 | BTS = batch trailer segment 31 | """ 32 | return line and ( 33 | line.strip()[:3] == "BHS" 34 | or (line.count("MSH") > 1 and line.strip()[:3] != "FHS") 35 | ) 36 | 37 | 38 | def isfile(line): 39 | """ 40 | Files are wrapped in FHS / FTS, or may be a batch 41 | FHS = file header segment 42 | FTS = file trailer segment 43 | """ 44 | return line and (line.strip()[:3] == "FHS" or isbatch(line)) 45 | 46 | 47 | def split_file(hl7file): 48 | """ 49 | Given a file, split out the messages. 50 | Does not do any validation on the message. 51 | Throws away batch and file segments. 52 | """ 53 | rv = [] 54 | for line in hl7file.split("\r"): 55 | line = line.strip() 56 | if line[:3] in ["FHS", "BHS", "FTS", "BTS"]: 57 | continue 58 | if line[:3] == "MSH": 59 | newmsg = [line] 60 | rv.append(newmsg) 61 | else: 62 | if len(rv) == 0: 63 | logger.error("Segment received before message header [%s]", line) 64 | continue 65 | rv[-1].append(line) 66 | rv = ["\r".join(msg) for msg in rv] 67 | for i, msg in enumerate(rv): 68 | if not msg[-1] == "\r": 69 | rv[i] = msg + "\r" 70 | return rv 71 | 72 | 73 | alphanumerics = string.ascii_uppercase + string.digits 74 | 75 | 76 | def generate_message_control_id(): 77 | """Generate a unique 20 character message id. 78 | 79 | See http://www.hl7resources.com/Public/index.html?a55433.htm 80 | """ 81 | d = datetime.datetime.utcnow() 82 | # Strip off the decade, ID only has to be unique for 3 years. 83 | # So now we have a 16 char timestamp. 84 | timestamp = d.strftime("%y%j%H%M%S%f")[1:] 85 | # Add 4 chars of uniqueness 86 | unique = "".join(random.sample(alphanumerics, 4)) 87 | return timestamp + unique 88 | 89 | 90 | def escape(container, field, app_map=None): 91 | """ 92 | See: http://www.hl7standards.com/blog/2006/11/02/hl7-escape-sequences/ 93 | 94 | To process this correctly, the full set of separators (MSH.1/MSH.2) needs to be known. 95 | 96 | Pass through the message. Replace recognised characters with their escaped 97 | version. Return an ascii encoded string. 98 | 99 | Functionality: 100 | 101 | * Replace separator characters (2.10.4) 102 | * replace application defined characters (2.10.7) 103 | * Replace non-ascii values with hex versions using HL7 conventions. 104 | 105 | Incomplete: 106 | 107 | * replace highlight characters (2.10.3) 108 | * How to handle the rich text substitutions. 109 | * Merge contiguous hex values 110 | """ 111 | if not field: 112 | return field 113 | 114 | esc = str(container.esc) 115 | 116 | DEFAULT_MAP = { 117 | container.separators[1]: "F", # 2.10.4 118 | container.separators[2]: "R", 119 | container.separators[3]: "S", 120 | container.separators[4]: "T", 121 | container.esc: "E", 122 | "\r": ".br", # 2.10.6 123 | } 124 | 125 | rv = [] 126 | for offset, c in enumerate(field): 127 | if app_map and c in app_map: 128 | rv.append(esc + app_map[c] + esc) 129 | elif c in DEFAULT_MAP: 130 | rv.append(esc + DEFAULT_MAP[c] + esc) 131 | elif ord(c) >= 0x20 and ord(c) <= 0x7E: 132 | rv.append(c) 133 | else: 134 | rv.append("%sX%2x%s" % (esc, ord(c), esc)) 135 | 136 | return "".join(rv) 137 | 138 | 139 | def unescape(container, field, app_map=None): # noqa: C901 140 | """ 141 | See: http://www.hl7standards.com/blog/2006/11/02/hl7-escape-sequences/ 142 | 143 | To process this correctly, the full set of separators (MSH.1/MSH.2) needs to be known. 144 | 145 | This will convert the identifiable sequences. 146 | If the application provides mapping, these are also used. 147 | Items which cannot be mapped are removed 148 | 149 | For example, the App Map count provide N, H, Zxxx values 150 | 151 | Chapter 2: Section 2.10 152 | 153 | At the moment, this functionality can: 154 | 155 | * replace the parsing characters (2.10.4) 156 | * replace highlight characters (2.10.3) 157 | * replace hex characters. (2.10.5) 158 | * replace rich text characters (2.10.6) 159 | * replace application defined characters (2.10.7) 160 | 161 | It cannot: 162 | 163 | * switch code pages / ISO IR character sets 164 | """ 165 | if not field or field.find(container.esc) == -1: 166 | return field 167 | 168 | DEFAULT_MAP = { 169 | "H": "_", # Override using the APP MAP: 2.10.3 170 | "N": "_", # Override using the APP MAP 171 | "F": container.separators[1], # 2.10.4 172 | "R": container.separators[2], 173 | "S": container.separators[3], 174 | "T": container.separators[4], 175 | "E": container.esc, 176 | ".br": "\r", # 2.10.6 177 | ".sp": "\r", 178 | ".fi": "", 179 | ".nf": "", 180 | ".in": " ", 181 | ".ti": " ", 182 | ".sk": " ", 183 | ".ce": "\r", 184 | } 185 | 186 | rv = [] 187 | collecting = [] 188 | in_seq = False 189 | for offset, c in enumerate(field): 190 | if in_seq: 191 | if c == container.esc: 192 | in_seq = False 193 | value = "".join(collecting) 194 | collecting = [] 195 | if not value: 196 | logger.warning( 197 | "Error unescaping value [%s], empty sequence found at %d", 198 | field, 199 | offset, 200 | ) 201 | continue 202 | if app_map and value in app_map: 203 | rv.append(app_map[value]) 204 | elif value in DEFAULT_MAP: 205 | rv.append(DEFAULT_MAP[value]) 206 | elif value.startswith(".") and ( 207 | (app_map and value[:3] in app_map) or value[:3] in DEFAULT_MAP 208 | ): 209 | # Substitution with a number of repetitions defined (2.10.6) 210 | if app_map and value[:3] in app_map: 211 | ch = app_map[value[:3]] 212 | else: 213 | ch = DEFAULT_MAP[value[:3]] 214 | count = int(value[3:]) 215 | rv.append(ch * count) 216 | 217 | elif ( 218 | value[0] == "C" 219 | ): # Convert to new Single Byte character set : 2.10.2 220 | # Two HEX values, first value chooses the character set (ISO-IR), second gives the value 221 | logger.warning( 222 | "Error inline character sets [%s] not implemented, field [%s], offset [%s]", 223 | value, 224 | field, 225 | offset, 226 | ) 227 | elif value[0] == "M": # Switch to new Multi Byte character set : 2.10.2 228 | # Three HEX values, first value chooses the character set (ISO-IR), rest give the value 229 | logger.warning( 230 | "Error inline character sets [%s] not implemented, field [%s], offset [%s]", 231 | value, 232 | field, 233 | offset, 234 | ) 235 | elif value[0] == "X": # Hex encoded Bytes: 2.10.5 236 | value = value[1:] 237 | try: 238 | for off in range(0, len(value), 2): 239 | rv.append(chr(int(value[off : off + 2], 16))) 240 | except Exception: 241 | logger.exception( 242 | "Error decoding hex value [%s], field [%s], offset [%s]", 243 | value, 244 | field, 245 | offset, 246 | ) 247 | else: 248 | logger.exception( 249 | "Error decoding value [%s], field [%s], offset [%s]", 250 | value, 251 | field, 252 | offset, 253 | ) 254 | else: 255 | collecting.append(c) 256 | elif c == container.esc: 257 | in_seq = True 258 | else: 259 | rv.append(str(c)) 260 | 261 | return "".join(rv) 262 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "hl7" 7 | description = "Python library parsing HL7 v2.x messages" 8 | readme = "README.rst" 9 | requires-python = ">=3.9" 10 | license = { file = "LICENSE" } 11 | version = "0.4.6.dev0" 12 | authors = [ 13 | { name = "John Paulett", email = "john@paulett.org" } 14 | ] 15 | keywords = [ 16 | "HL7", 17 | "Health Level 7", 18 | "healthcare", 19 | "health care", 20 | "medical record", 21 | "mllp" 22 | ] 23 | classifiers = [ 24 | "License :: OSI Approved :: BSD License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Development Status :: 3 - Alpha", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: Healthcare Industry", 31 | "Topic :: Communications", 32 | "Topic :: Scientific/Engineering :: Medical Science Apps.", 33 | "Topic :: Software Development :: Libraries :: Python Modules" 34 | ] 35 | 36 | [project.urls] 37 | Source = "https://github.com/johnpaulett/python-hl7" 38 | 39 | [project.scripts] 40 | mllp_send = "hl7.client:mllp_send" 41 | 42 | [project.optional-dependencies] 43 | # Development requirements previously listed in requirements.txt 44 | dev = [ 45 | "tox==4.26.0", 46 | "Sphinx==7.2.6", 47 | "coverage==6.3.2", 48 | "ruff==0.4.4", 49 | "wheel==0.38.1", 50 | "setuptools==80.9.0", 51 | "hatchling", 52 | "commitizen==3.13.0" 53 | ] 54 | 55 | [tool.hatch.build.targets.sdist] 56 | include = [ 57 | "hl7", 58 | "tests", 59 | "docs", 60 | "README.rst", 61 | "LICENSE", 62 | "AUTHORS", 63 | "MANIFEST.in" 64 | ] 65 | 66 | 67 | [tool.ruff] 68 | line-length = 88 69 | target-version = "py39" 70 | exclude = [".git", "env", "__pycache__", "build", "dist"] 71 | 72 | [tool.ruff.lint] 73 | ignore = ["E203", "E501"] 74 | 75 | [tool.commitizen] 76 | name = "cz_conventional_commits" 77 | version = "0.4.6.dev0" 78 | tag_format = "$version" 79 | version_files = [ 80 | "pyproject.toml:version", 81 | "hl7/__init__.py:__version__", 82 | ] 83 | changelog_file = "docs/changelog.rst" 84 | update_changelog_on_bump = true 85 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpaulett/python-hl7/26022d555ca18f28d40490aa43d1d2f30aeeaeb2/tests/__init__.py -------------------------------------------------------------------------------- /tests/samples.py: -------------------------------------------------------------------------------- 1 | # Sample message from HL7 Normative Edition 2 | # http://healthinfo.med.dal.ca/hl7intro/CDA_R2_normativewebedition/help/v3guide/v3guide.htm#v3gexamples 3 | sample_hl7 = "\r".join( 4 | [ 5 | "MSH|^~\\&|GHH LAB|ELAB-3|GHH OE|BLDG4|200202150930||ORU^R01|CNTRL-3456|P|2.4", 6 | "PID|||555-44-4444||EVERYWOMAN^EVE^E^^^^L|JONES|196203520|F|||153 FERNWOOD DR.^^STATESVILLE^OH^35292||(206)3345232|(206)752-121||||AC555444444||67-A4335^OH^20030520", 7 | "OBR|1|845439^GHH OE|1045813^GHH LAB|1554-5^GLUCOSE|||200202150730||||||||555-55-5555^PRIMARY^PATRICIA P^^^^MD^^LEVEL SEVEN HEALTHCARE, INC.|||||||||F||||||444-44-4444^HIPPOCRATES^HOWARD H^^^^MD", 8 | "OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F", 9 | "OBX|2|FN|1553-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F\r", 10 | ] 11 | ) 12 | 13 | # Example from: http://wiki.medical-objects.com.au/index.php/Hl7v2_parsing 14 | rep_sample_hl7 = "\r".join( 15 | [ 16 | "MSH|^~\\&|GHH LAB|ELAB-3|GHH OE|BLDG4|200202150930||ORU^R01|CNTRL-3456|P|2.4", 17 | "PID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2", 18 | "", 19 | ] 20 | ) 21 | 22 | # Source: http://www.health.vic.gov.au/hdss/vinah/2006-07/appendix-a-sample-messages.pdf 23 | sample_batch = "\r".join( 24 | [ 25 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 26 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 27 | "EVN|A04|20060705000000", 28 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 29 | "PD1||2", 30 | "NK1|1||1||||||||||||||||||2", 31 | "PV1|1|O||||^^^^^1", 32 | "BTS|1", 33 | "", 34 | ] 35 | ) 36 | 37 | sample_batch1 = "\r".join( 38 | [ 39 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 40 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 41 | "EVN|A04|20060705000000", 42 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 43 | "PD1||2", 44 | "NK1|1||1||||||||||||||||||2", 45 | "PV1|1|O||||^^^^^1", 46 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778891|P|2.5|||NE|NE|AU|ASCII", 47 | "EVN|A04|20060705000000", 48 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 49 | "PD1||2", 50 | "NK1|1||1||||||||||||||||||2", 51 | "PV1|1|O||||^^^^^1", 52 | "BTS|2", 53 | "", 54 | ] 55 | ) 56 | 57 | sample_batch2 = "\r".join( 58 | [ 59 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 60 | "EVN|A04|20060705000000", 61 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 62 | "PD1||2", 63 | "NK1|1||1||||||||||||||||||2", 64 | "PV1|1|O||||^^^^^1", 65 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778891|P|2.5|||NE|NE|AU|ASCII", 66 | "EVN|A04|20060705000000", 67 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 68 | "PD1||2", 69 | "NK1|1||1||||||||||||||||||2", 70 | "PV1|1|O||||^^^^^1", 71 | "", 72 | ] 73 | ) 74 | 75 | sample_batch3 = "\r".join( 76 | [ 77 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 78 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 79 | "EVN|A04|20060705000000", 80 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 81 | "PD1||2", 82 | "NK1|1||1||||||||||||||||||2", 83 | "PV1|1|O||||^^^^^1", 84 | "", 85 | ] 86 | ) 87 | 88 | 89 | sample_batch4 = "\r".join( 90 | [ 91 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 92 | "EVN|A04|20060705000000", 93 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 94 | "PD1||2", 95 | "NK1|1||1||||||||||||||||||2", 96 | "PV1|1|O||||^^^^^1", 97 | "BTS|1", 98 | "", 99 | ] 100 | ) 101 | 102 | sample_bad_batch = "\r".join( 103 | [ 104 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 105 | "EVN|A04|20060705000000", 106 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 107 | "PD1||2", 108 | "NK1|1||1||||||||||||||||||2", 109 | "PV1|1|O||||^^^^^1", 110 | "BTS|1", 111 | "", 112 | ] 113 | ) 114 | 115 | sample_bad_batch1 = "\r".join( 116 | [ 117 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 118 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 119 | "EVN|A04|20060705000000", 120 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123402||||abchs20070101123401-1", 121 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 122 | "PD1||2", 123 | "NK1|1||1||||||||||||||||||2", 124 | "PV1|1|O||||^^^^^1", 125 | "BTS|1", 126 | "", 127 | ] 128 | ) 129 | 130 | # Source: http://www.health.vic.gov.au/hdss/vinah/2006-07/appendix-a-sample-messages.pdf 131 | sample_file = "\r".join( 132 | [ 133 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 134 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 135 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 136 | "EVN|A04|20060705000000", 137 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 138 | "PD1||2", 139 | "NK1|1||1||||||||||||||||||2", 140 | "PV1|1|O||||^^^^^1", 141 | "BTS|1", 142 | "FTS|1", 143 | "", 144 | ] 145 | ) 146 | 147 | sample_file1 = "\r".join( 148 | [ 149 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 150 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 151 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 152 | "EVN|A04|20060705000000", 153 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 154 | "PD1||2", 155 | "NK1|1||1||||||||||||||||||2", 156 | "PV1|1|O||||^^^^^1", 157 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778891|P|2.5|||NE|NE|AU|ASCII", 158 | "EVN|A04|20060705000000", 159 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 160 | "PD1||2", 161 | "NK1|1||1||||||||||||||||||2", 162 | "PV1|1|O||||^^^^^1", 163 | "BTS|2", 164 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-2", 165 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 166 | "EVN|A04|20060705000000", 167 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 168 | "PD1||2", 169 | "NK1|1||1||||||||||||||||||2", 170 | "PV1|1|O||||^^^^^1", 171 | "BTS|1", 172 | "FTS|2", 173 | "", 174 | ] 175 | ) 176 | 177 | sample_file2 = "\r".join( 178 | [ 179 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 180 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 181 | "EVN|A04|20060705000000", 182 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 183 | "PD1||2", 184 | "NK1|1||1||||||||||||||||||2", 185 | "PV1|1|O||||^^^^^1", 186 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778891|P|2.5|||NE|NE|AU|ASCII", 187 | "EVN|A04|20060705000000", 188 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 189 | "PD1||2", 190 | "NK1|1||1||||||||||||||||||2", 191 | "PV1|1|O||||^^^^^1", 192 | "FTS|1", 193 | "", 194 | ] 195 | ) 196 | 197 | sample_file3 = "\r".join( 198 | [ 199 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 200 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 201 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 202 | "EVN|A04|20060705000000", 203 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 204 | "PD1||2", 205 | "NK1|1||1||||||||||||||||||2", 206 | "PV1|1|O||||^^^^^1", 207 | "BTS|1", 208 | "", 209 | ] 210 | ) 211 | 212 | sample_file4 = "\r".join( 213 | [ 214 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 215 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 216 | "EVN|A04|20060705000000", 217 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 218 | "PD1||2", 219 | "NK1|1||1||||||||||||||||||2", 220 | "PV1|1|O||||^^^^^1", 221 | "BTS|1", 222 | "FTS|1", 223 | "", 224 | ] 225 | ) 226 | 227 | sample_file5 = "\r".join( 228 | [ 229 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 230 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 231 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 232 | "EVN|A04|20060705000000", 233 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 234 | "PD1||2", 235 | "NK1|1||1||||||||||||||||||2", 236 | "PV1|1|O||||^^^^^1", 237 | "FTS|1", 238 | "", 239 | ] 240 | ) 241 | 242 | sample_file6 = "\r".join( 243 | [ 244 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 245 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 246 | "EVN|A04|20060705000000", 247 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 248 | "PD1||2", 249 | "NK1|1||1||||||||||||||||||2", 250 | "PV1|1|O||||^^^^^1", 251 | "BTS|1", 252 | "FTS|1", 253 | "", 254 | ] 255 | ) 256 | 257 | sample_bad_file = "\r".join( 258 | [ 259 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 260 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 261 | "EVN|A04|20060705000000", 262 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 263 | "PD1||2", 264 | "NK1|1||1||||||||||||||||||2", 265 | "PV1|1|O||||^^^^^1", 266 | "BTS|1", 267 | "FTS|1", 268 | "", 269 | ] 270 | ) 271 | 272 | sample_bad_file1 = "\r".join( 273 | [ 274 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 275 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 276 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 277 | "EVN|A04|20060705000000", 278 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123402||||abchs20070101123401-1", 279 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 280 | "PD1||2", 281 | "NK1|1||1||||||||||||||||||2", 282 | "PV1|1|O||||^^^^^1", 283 | "BTS|1", 284 | "FTS|1", 285 | "", 286 | ] 287 | ) 288 | 289 | sample_bad_file2 = "\r".join( 290 | [ 291 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 292 | "BHS|^~\\&||ABCHS||AUSDHSV|20070101123401||||abchs20070101123401-1", 293 | "MSH|^~\\&||ABCHS||AUSDHSV|20070101112951||ADT^A04^ADT_A01|12334456778890|P|2.5|||NE|NE|AU|ASCII", 294 | "EVN|A04|20060705000000", 295 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123402|||abchs20070101123401.hl7|", 296 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 297 | "PD1||2", 298 | "NK1|1||1||||||||||||||||||2", 299 | "PV1|1|O||||^^^^^1", 300 | "BTS|1", 301 | "FTS|1", 302 | "", 303 | ] 304 | ) 305 | 306 | sample_bad_file3 = "\r".join( 307 | [ 308 | "FHS|^~\\&||ABCHS||AUSDHSV|20070101123401|||abchs20070101123401.hl7|", 309 | "EVN|A04|20060705000000", 310 | "PID|1||0000112234^^^100^A||XXXXXXXXXX^^^^^^S||10131113|1||4|^^RICHMOND^^3121||||1201||||||||1100|||||||||AAA", 311 | "PD1||2", 312 | "NK1|1||1||||||||||||||||||2", 313 | "PV1|1|O||||^^^^^1", 314 | "FTS|1", 315 | "", 316 | ] 317 | ) 318 | 319 | sample_msh = "\r".join( 320 | [ 321 | "MSH|^~\\&|HNAM_PM|HNA500|AIG||20131017140041||ADT^A01|Q150084616T145947960|P|2.3", 322 | "PID|1|2148790^^^MSH_MRN^MR|2148790^^^MSH_MRN^MR~162840^^^MSH_EMPI^CM|3722^0^^MSH_DTC^REFE~184737^0^^IID^DONOR ~Q2147670^0^^MSQ_MRN|RUFUSS^MELLODIAL^^^^^CURRENT||19521129|F|RUFUSS^MELLODIAL^^^^^PREVIOUS|OT|221 CANVIEW AVENUE^66-D^BRONX^NY^10454^USA^HOME^^058||3472444150^HOME~(000)000-0000^ALTERNATE||ENGLISH|M|PEN|O75622322^^^MSH_FIN_NBR^FIN NB|125544697|||HIS|||0", 323 | 'PV1|0001|I|MBN1^MBN1^06|4| 863968||03525^FARP^YONAN|03525^FARP^YONAN|""|NUR|||N|5|| U|03525^FARP^YONAN|I|01|T22~SLF|||||||||||||||||||E||AC|||20140210225300|""', 324 | 'DG1|0001|I9|440.21^ATHEROSCLEROSIS W/INT CLAUDCTN^I9|ATHEROSCLEROSIS W/INT CLAUDCTN|""|A|||||||.00||9', 325 | 'IN1|0001|A10A|A10|HIP COMP MCAID|PO BOX 223^""^NEW YORK^NY^10116^US^^^""|HIP ON LINE|""|""|""|||""|""|25892261^""^""|C|BENNETT^NELLY|4^SELF|10981226|322-10 GOODLIN AVE^APT B31^FLUSHING^NY^11355^US^^^61|Y|""||||||Y||""|||||||-JNJ45517', 326 | 'IN2||062420044|""|||""|||||||||||||||||||60094|""|||||||||||||||||||||||||||||||', 327 | 'IN1|0002|GMED|""|MEDICAID|""|""|""|""|""|||""|""||X|BENNETT^NELLY|4^SELF|10981226|322-10 GOODLIN AVE^APT B31^FLUSHING^NY^11355^US^^^61|""|""||||||""||||||', 328 | 'IN2||062420044|""|||""|||||||||||||||||||""|""||||||||||||||||||||||||||||||||""', 329 | 'IN1|0003|SLFJ|""|SELF-PAY|""|""|""|""|""|||""|""||P|BENNETT^NELLY|4^SELF|10981226|322-10 GOODLIN AVE^APT B31^FLUSHING^NY^11355^US^^^61|""|""||||||""||||||', 330 | "", 331 | ] 332 | ) 333 | -------------------------------------------------------------------------------- /tests/test_accessor.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from hl7 import Accessor, Field, Message, Segment 4 | 5 | 6 | class AccessorTest(TestCase): 7 | def test_key(self): 8 | self.assertEqual("FOO", Accessor("FOO").key) 9 | self.assertEqual("FOO2", Accessor("FOO", 2).key) 10 | self.assertEqual("FOO2.3", Accessor("FOO", 2, 3).key) 11 | self.assertEqual("FOO2.3.1.4.6", Accessor("FOO", 2, 3, 1, 4, 6).key) 12 | 13 | def test_parse(self): 14 | self.assertEqual(Accessor("FOO"), Accessor.parse_key("FOO")) 15 | self.assertEqual( 16 | Accessor("FOO", 2, 3, 1, 4, 6), Accessor.parse_key("FOO2.3.1.4.6") 17 | ) 18 | 19 | def test_equality(self): 20 | self.assertEqual(Accessor("FOO", 1, 3, 4), Accessor("FOO", 1, 3, 4)) 21 | self.assertNotEqual(Accessor("FOO", 1), Accessor("FOO", 2)) 22 | 23 | def test_string(self): 24 | SEP = "|^~\\&" 25 | CR_SEP = "\r" 26 | MSH = Segment(SEP[0], [Field(SEP[2], ["MSH"])]) 27 | MSA = Segment(SEP[0], [Field(SEP[2], ["MSA"])]) 28 | response = Message(CR_SEP, [MSH, MSA]) 29 | response["MSH.F1.R1"] = SEP[0] 30 | response["MSH.F2.R1"] = SEP[1:] 31 | self.assertEqual(str(response), "MSH|^~\\&|\rMSA\r") 32 | 33 | response["MSH.F9.R1.C1"] = "ORU" 34 | response["MSH.F9.R1.C2"] = "R01" 35 | response["MSH.F9.R1.C3"] = "" 36 | response["MSH.F12.R1"] = "2.4" 37 | response["MSA.F1.R1"] = "AA" 38 | response["MSA.F3.R1"] = "Application Message" 39 | self.assertEqual( 40 | str(response), 41 | "MSH|^~\\&|||||||ORU^R01^|||2.4\rMSA|AA||Application Message\r", 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | from argparse import Namespace 4 | from shutil import rmtree 5 | from tempfile import mkdtemp 6 | from unittest import TestCase 7 | from unittest.mock import Mock, patch 8 | 9 | import hl7 10 | from hl7 import __version__ as hl7_version 11 | from hl7.client import CR, EB, SB, MLLPClient, MLLPException, mllp_send 12 | 13 | 14 | class MLLPClientTest(TestCase): 15 | def setUp(self): 16 | # use a mock version of socket 17 | self.socket_patch = patch("hl7.client.socket.socket") 18 | self.mock_socket = self.socket_patch.start() 19 | 20 | self.client = MLLPClient("localhost", 6666) 21 | 22 | def tearDown(self): 23 | # unpatch socket 24 | self.socket_patch.stop() 25 | 26 | def test_connect(self): 27 | self.mock_socket.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM) 28 | self.client.socket.connect.assert_called_once_with(("localhost", 6666)) 29 | 30 | def test_close(self): 31 | self.client.close() 32 | self.client.socket.close.assert_called_once_with() 33 | 34 | def test_send(self): 35 | self.client.socket.recv.return_value = "thanks" 36 | 37 | result = self.client.send("foobar\n") 38 | self.assertEqual(result, "thanks") 39 | 40 | self.client.socket.sendall.assert_called_once_with("foobar\n") 41 | self.client.socket.recv.assert_called_once_with(4096) 42 | 43 | def test_send_message_unicode(self): 44 | self.client.socket.recv.return_value = "thanks" 45 | 46 | result = self.client.send_message("foobar") 47 | self.assertEqual(result, "thanks") 48 | 49 | self.client.socket.sendall.assert_called_once_with(b"\x0bfoobar\x1c\x0d") 50 | 51 | def test_send_message_bytestring(self): 52 | self.client.socket.recv.return_value = "thanks" 53 | 54 | result = self.client.send_message(b"foobar") 55 | self.assertEqual(result, "thanks") 56 | 57 | self.client.socket.sendall.assert_called_once_with(b"\x0bfoobar\x1c\x0d") 58 | 59 | def test_send_message_hl7_message(self): 60 | self.client.socket.recv.return_value = "thanks" 61 | 62 | message = hl7.parse(r"MSH|^~\&|GHH LAB|ELAB") 63 | 64 | result = self.client.send_message(message) 65 | self.assertEqual(result, "thanks") 66 | 67 | self.client.socket.sendall.assert_called_once_with( 68 | b"\x0bMSH|^~\\&|GHH LAB|ELAB\r\x1c\x0d" 69 | ) 70 | 71 | def test_context_manager(self): 72 | with MLLPClient("localhost", 6666) as client: 73 | client.send("hello world") 74 | 75 | self.client.socket.sendall.assert_called_once_with("hello world") 76 | self.client.socket.close.assert_called_once_with() 77 | 78 | def test_context_manager_exception(self): 79 | with self.assertRaises(Exception): 80 | with MLLPClient("localhost", 6666): 81 | raise Exception() 82 | 83 | # socket.close should be called via the with statement 84 | self.client.socket.close.assert_called_once_with() 85 | 86 | 87 | class MLLPSendTest(TestCase): 88 | def setUp(self): 89 | # patch to avoid touching sys and socket 90 | self.socket_patch = patch("hl7.client.socket.socket") 91 | self.mock_socket = self.socket_patch.start() 92 | self.mock_socket().recv.return_value = "thanks" 93 | 94 | self.stdout_patch = patch("hl7.client.stdout") 95 | self.mock_stdout = self.stdout_patch.start() 96 | 97 | self.stdin_patch = patch("hl7.client.stdin") 98 | self.mock_stdin = self.stdin_patch.start() 99 | 100 | self.stderr_patch = patch("hl7.client.stderr") 101 | self.mock_stderr = self.stderr_patch.start() 102 | 103 | self.exit_patch = patch("hl7.client.sys.exit") 104 | self.mock_exit = self.exit_patch.start() 105 | 106 | # we need a temporary directory 107 | self.dir = mkdtemp() 108 | self.write(SB + b"foobar" + EB + CR) 109 | 110 | self.option_values = Namespace( 111 | port=6661, 112 | filename=os.path.join(self.dir, "test.hl7"), 113 | verbose=True, 114 | loose=False, 115 | version=False, 116 | server="localhost", 117 | ) 118 | 119 | self.options_patch = patch("hl7.client.argparse.ArgumentParser") 120 | option_parser = self.options_patch.start() 121 | self.mock_options = Mock() 122 | option_parser.return_value = self.mock_options 123 | self.mock_options.parse_args.return_value = self.option_values 124 | 125 | def tearDown(self): 126 | # unpatch 127 | self.socket_patch.stop() 128 | self.options_patch.stop() 129 | self.stdout_patch.stop() 130 | self.stdin_patch.stop() 131 | self.stderr_patch.stop() 132 | self.exit_patch.stop() 133 | 134 | # clean up the temp directory 135 | rmtree(self.dir) 136 | 137 | def write(self, content, path="test.hl7"): 138 | with open(os.path.join(self.dir, path), "wb") as f: 139 | f.write(content) 140 | 141 | def test_send(self): 142 | mllp_send() 143 | 144 | self.mock_socket().connect.assert_called_once_with(("localhost", 6661)) 145 | self.mock_socket().sendall.assert_called_once_with(SB + b"foobar" + EB + CR) 146 | self.mock_stdout.assert_called_once_with("thanks") 147 | self.assertFalse(self.mock_exit.called) 148 | 149 | def test_send_multiple(self): 150 | self.mock_socket().recv.return_value = "thanks" 151 | self.write(SB + b"foobar" + EB + CR + SB + b"hello" + EB + CR) 152 | 153 | mllp_send() 154 | 155 | self.assertEqual( 156 | self.mock_socket().sendall.call_args_list[0][0][0], SB + b"foobar" + EB + CR 157 | ) 158 | self.assertEqual( 159 | self.mock_socket().sendall.call_args_list[1][0][0], SB + b"hello" + EB + CR 160 | ) 161 | 162 | def test_leftover_buffer(self): 163 | self.write(SB + b"foobar" + EB + CR + SB + b"stuff") 164 | 165 | self.assertRaises(MLLPException, mllp_send) 166 | 167 | self.mock_socket().sendall.assert_called_once_with(SB + b"foobar" + EB + CR) 168 | 169 | def test_quiet(self): 170 | self.option_values.verbose = False 171 | 172 | mllp_send() 173 | 174 | self.mock_socket().sendall.assert_called_once_with(SB + b"foobar" + EB + CR) 175 | self.assertFalse(self.mock_stdout.called) 176 | 177 | def test_port(self): 178 | self.option_values.port = 7890 179 | 180 | mllp_send() 181 | 182 | self.mock_socket().connect.assert_called_once_with(("localhost", 7890)) 183 | 184 | def test_stdin(self): 185 | self.option_values.filename = None 186 | self.mock_stdin.return_value = FakeStream() 187 | 188 | mllp_send() 189 | 190 | self.mock_socket().sendall.assert_called_once_with(SB + b"hello" + EB + CR) 191 | 192 | def test_loose_no_stdin(self): 193 | self.option_values.loose = True 194 | self.option_values.filename = None 195 | self.mock_stdin.return_value = FakeStream() 196 | 197 | mllp_send() 198 | 199 | self.assertFalse(self.mock_socket().sendall.called) 200 | self.mock_stderr().write.assert_called_with("--loose requires --file\n") 201 | self.mock_exit.assert_called_with(1) 202 | 203 | def test_loose_windows_newline(self): 204 | self.option_values.loose = True 205 | self.write(SB + b"MSH|^~\\&|foo\r\nbar\r\n" + EB + CR) 206 | 207 | mllp_send() 208 | 209 | self.mock_socket().sendall.assert_called_once_with( 210 | SB + b"MSH|^~\\&|foo\rbar" + EB + CR 211 | ) 212 | 213 | def test_loose_unix_newline(self): 214 | self.option_values.loose = True 215 | self.write(SB + b"MSH|^~\\&|foo\nbar\n" + EB + CR) 216 | 217 | mllp_send() 218 | 219 | self.mock_socket().sendall.assert_called_once_with( 220 | SB + b"MSH|^~\\&|foo\rbar" + EB + CR 221 | ) 222 | 223 | def test_loose_no_mllp_characters(self): 224 | self.option_values.loose = True 225 | self.write(b"MSH|^~\\&|foo\r\nbar\r\n") 226 | 227 | mllp_send() 228 | 229 | self.mock_socket().sendall.assert_called_once_with( 230 | SB + b"MSH|^~\\&|foo\rbar" + EB + CR 231 | ) 232 | 233 | def test_loose_send_mutliple(self): 234 | self.option_values.loose = True 235 | self.mock_socket().recv.return_value = "thanks" 236 | self.write(b"MSH|^~\\&|1\r\nOBX|1\r\nMSH|^~\\&|2\r\nOBX|2\r\n") 237 | 238 | mllp_send() 239 | 240 | self.assertEqual( 241 | self.mock_socket().sendall.call_args_list[0][0][0], 242 | SB + b"MSH|^~\\&|1\rOBX|1" + EB + CR, 243 | ) 244 | self.assertEqual( 245 | self.mock_socket().sendall.call_args_list[1][0][0], 246 | SB + b"MSH|^~\\&|2\rOBX|2" + EB + CR, 247 | ) 248 | 249 | def test_version(self): 250 | self.option_values.version = True 251 | 252 | mllp_send() 253 | 254 | self.assertFalse(self.mock_socket().connect.called) 255 | self.mock_stdout.assert_called_once_with(str(hl7_version)) 256 | 257 | def test_missing_server(self): 258 | self.option_values.server = None 259 | 260 | mllp_send() 261 | 262 | self.assertFalse(self.mock_socket().connect.called) 263 | self.mock_options.print_usage.assert_called_once_with() 264 | self.mock_stderr().write.assert_called_with("server required\n") 265 | self.mock_exit.assert_called_with(1) 266 | 267 | def test_quiet_cli_option_suppresses_output(self): 268 | argv = [ 269 | "mllp_send", 270 | "--file", 271 | os.path.join(self.dir, "test.hl7"), 272 | "--quiet", 273 | "localhost", 274 | ] 275 | # stop patched parser to use real CLI parsing 276 | self.options_patch.stop() 277 | with patch("hl7.client.sys.argv", argv): 278 | mllp_send() 279 | self.assertFalse(self.mock_stdout.called) 280 | 281 | 282 | class FakeStream: 283 | count = 0 284 | 285 | def read(self, buf): 286 | self.count += 1 287 | if self.count == 1: 288 | return SB + b"hello" + EB + CR 289 | else: 290 | return b"" 291 | -------------------------------------------------------------------------------- /tests/test_construction.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import hl7 4 | 5 | from .samples import rep_sample_hl7 6 | 7 | SEP = r"|^~\&" 8 | CR_SEP = "\r" 9 | 10 | 11 | class ConstructionTest(TestCase): 12 | def test_create_msg(self): 13 | # Create a message 14 | MSH = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSH"])]) 15 | MSA = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSA"])]) 16 | response = hl7.Message(CR_SEP, [MSH, MSA]) 17 | response["MSH.F1.R1"] = SEP[0] 18 | response["MSH.F2.R1"] = SEP[1:] 19 | self.assertEqual(str(response), "MSH|^~\\&|\rMSA\r") 20 | 21 | def test_append(self): 22 | # Append a segment to a message 23 | MSH = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSH"])]) 24 | response = hl7.Message(CR_SEP, [MSH]) 25 | response["MSH.F1.R1"] = SEP[0] 26 | response["MSH.F2.R1"] = SEP[1:] 27 | MSA = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSA"])]) 28 | response.append(MSA) 29 | self.assertEqual(str(response), "MSH|^~\\&|\rMSA\r") 30 | 31 | def test_append_from_source(self): 32 | # Copy a segment between messages 33 | MSH = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSH"])]) 34 | MSA = hl7.Segment(SEP[0], [hl7.Field(SEP[2], ["MSA"])]) 35 | response = hl7.Message(CR_SEP, [MSH, MSA]) 36 | response["MSH.F1.R1"] = SEP[0] 37 | response["MSH.F2.R1"] = SEP[1:] 38 | self.assertEqual(str(response), "MSH|^~\\&|\rMSA\r") 39 | src_msg = hl7.parse(rep_sample_hl7) 40 | PID = src_msg["PID"][0] 41 | self.assertEqual( 42 | str(PID), 43 | "PID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2", 44 | ) 45 | response.append(PID) 46 | self.assertEqual( 47 | str(response), 48 | "MSH|^~\\&|\rMSA\rPID|Field1|Component1^Component2|Component1^Sub-Component1&Sub-Component2^Component3|Repeat1~Repeat2\r", 49 | ) 50 | -------------------------------------------------------------------------------- /tests/test_containers.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import hl7 4 | from hl7 import Field, Segment 5 | 6 | from .samples import sample_hl7 7 | 8 | 9 | class ContainerTest(TestCase): 10 | def test_unicode(self): 11 | msg = hl7.parse(sample_hl7) 12 | self.assertEqual(str(msg), sample_hl7) 13 | self.assertEqual( 14 | str(msg[3][3]), "1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN" 15 | ) 16 | 17 | def test_container_unicode(self): 18 | c = hl7.Container("|") 19 | c.extend(["1", "b", "data"]) 20 | self.assertEqual(str(c), "1|b|data") 21 | 22 | 23 | class MessageTest(TestCase): 24 | def test_segments(self): 25 | msg = hl7.parse(sample_hl7) 26 | s = msg.segments("OBX") 27 | self.assertEqual(len(s), 2) 28 | self.assertIsInstance(s[0], Segment) 29 | self.assertEqual(s[0][0:3], [["OBX"], ["1"], ["SN"]]) 30 | self.assertEqual(s[1][0:3], [["OBX"], ["2"], ["FN"]]) 31 | 32 | self.assertIsInstance(s[0][1], Field) 33 | 34 | def test_segments_does_not_exist(self): 35 | msg = hl7.parse(sample_hl7) 36 | self.assertRaises(KeyError, msg.segments, "BAD") 37 | 38 | def test_segment(self): 39 | msg = hl7.parse(sample_hl7) 40 | s = msg.segment("OBX") 41 | self.assertEqual(s[0:3], [["OBX"], ["1"], ["SN"]]) 42 | 43 | def test_segment_does_not_exist(self): 44 | msg = hl7.parse(sample_hl7) 45 | self.assertRaises(KeyError, msg.segment, "BAD") 46 | 47 | def test_segments_dict_key(self): 48 | msg = hl7.parse(sample_hl7) 49 | s = msg["OBX"] 50 | self.assertEqual(len(s), 2) 51 | self.assertEqual(s[0][0:3], [["OBX"], ["1"], ["SN"]]) 52 | self.assertEqual(s[1][0:3], [["OBX"], ["2"], ["FN"]]) 53 | 54 | def test_MSH_1_field(self): 55 | msg = hl7.parse(sample_hl7) 56 | f = msg["MSH.1"] 57 | self.assertEqual(len(f), 1) 58 | self.assertEqual(f, "|") 59 | 60 | def test_MSH_2_field(self): 61 | msg = hl7.parse(sample_hl7) 62 | f = msg["MSH.2"] 63 | self.assertEqual(len(f), 4) 64 | self.assertEqual(f, "^~\\&") 65 | 66 | def test_get_slice(self): 67 | msg = hl7.parse(sample_hl7) 68 | s = msg.segments("OBX")[0] 69 | self.assertIsInstance(s, Segment) 70 | self.assertIsInstance(s[0:3], Segment) 71 | 72 | def test_ack(self): 73 | msg = hl7.parse(sample_hl7) 74 | ack = msg.create_ack() 75 | self.assertEqual(msg["MSH.1"], ack["MSH.1"]) 76 | self.assertEqual(msg["MSH.2"], ack["MSH.2"]) 77 | self.assertEqual("ACK", ack["MSH.9.1.1"]) 78 | self.assertEqual(msg["MSH.9.1.2"], ack["MSH.9.1.2"]) 79 | self.assertEqual("ACK", ack["MSH.9.1.3"]) 80 | self.assertNotEqual(msg["MSH.7"], ack["MSH.7"]) 81 | self.assertNotEqual(msg["MSH.10"], ack["MSH.10"]) 82 | self.assertEqual("AA", ack["MSA.1"]) 83 | self.assertEqual(msg["MSH.10"], ack["MSA.2"]) 84 | self.assertEqual(20, len(ack["MSH.10"])) 85 | self.assertEqual(msg["MSH.5"], ack["MSH.3"]) 86 | self.assertEqual(msg["MSH.6"], ack["MSH.4"]) 87 | self.assertEqual(msg["MSH.3"], ack["MSH.5"]) 88 | self.assertEqual(msg["MSH.4"], ack["MSH.6"]) 89 | ack2 = msg.create_ack( 90 | ack_code="AE", message_id="testid", application="python", facility="test" 91 | ) 92 | self.assertEqual("AE", ack2["MSA.1"]) 93 | self.assertEqual("testid", ack2["MSH.10"]) 94 | self.assertEqual("python", ack2["MSH.3"]) 95 | self.assertEqual("test", ack2["MSH.4"]) 96 | self.assertNotEqual(ack["MSH.10"], ack2["MSH.10"]) 97 | 98 | 99 | class TestMessage(hl7.Message): 100 | pass 101 | 102 | 103 | class TestSegment(hl7.Segment): 104 | pass 105 | 106 | 107 | class TestField(hl7.Field): 108 | pass 109 | 110 | 111 | class TestRepetition(hl7.Repetition): 112 | pass 113 | 114 | 115 | class TestComponent(hl7.Component): 116 | pass 117 | 118 | 119 | class TestFactory(hl7.Factory): 120 | create_message = TestMessage 121 | create_segment = TestSegment 122 | create_field = TestField 123 | create_repetition = TestRepetition 124 | create_component = TestComponent 125 | 126 | 127 | class FactoryTest(TestCase): 128 | def test_parse(self): 129 | msg = hl7.parse(sample_hl7, factory=TestFactory) 130 | self.assertIsInstance(msg, TestMessage) 131 | s = msg.segments("OBX") 132 | self.assertIsInstance(s[0], TestSegment) 133 | self.assertIsInstance(s[0](3), TestField) 134 | self.assertIsInstance(s[0](3)(1), TestRepetition) 135 | self.assertIsInstance(s[0](3)(1)(1), TestComponent) 136 | self.assertEqual("1554-5", s[0](3)(1)(1)(1)) 137 | 138 | def test_ack(self): 139 | msg = hl7.parse(sample_hl7, factory=TestFactory) 140 | ack = msg.create_ack() 141 | self.assertIsInstance(ack, TestMessage) 142 | self.assertIsInstance(ack(1)(9), TestField) 143 | self.assertIsInstance(ack(1)(9)(1), TestRepetition) 144 | self.assertIsInstance(ack(1)(9)(1)(2), TestComponent) 145 | self.assertEqual("R01", ack(1)(9)(1)(2)(1)) 146 | -------------------------------------------------------------------------------- /tests/test_datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | 4 | from hl7.datatypes import _UTCOffset, parse_datetime 5 | 6 | 7 | class DatetimeTest(TestCase): 8 | def test_parse_date(self): 9 | self.assertEqual(datetime(1901, 2, 13), parse_datetime("19010213")) 10 | 11 | def test_parse_datetime(self): 12 | self.assertEqual( 13 | datetime(2014, 3, 11, 14, 25, 33), parse_datetime("20140311142533") 14 | ) 15 | 16 | def test_parse_datetime_frac(self): 17 | self.assertEqual( 18 | datetime(2014, 3, 11, 14, 25, 33, 100000), 19 | parse_datetime("20140311142533.1"), 20 | ) 21 | self.assertEqual( 22 | datetime(2014, 3, 11, 14, 25, 33, 10000), 23 | parse_datetime("20140311142533.01"), 24 | ) 25 | self.assertEqual( 26 | datetime(2014, 3, 11, 14, 25, 33, 1000), 27 | parse_datetime("20140311142533.001"), 28 | ) 29 | self.assertEqual( 30 | datetime(2014, 3, 11, 14, 25, 33, 100), 31 | parse_datetime("20140311142533.0001"), 32 | ) 33 | 34 | def test_parse_tz(self): 35 | self.assertEqual( 36 | datetime(2014, 3, 11, 14, 12, tzinfo=_UTCOffset(330)), 37 | parse_datetime("201403111412+0530"), 38 | ) 39 | self.assertEqual( 40 | datetime(2014, 3, 11, 14, 12, 20, tzinfo=_UTCOffset(-300)), 41 | parse_datetime("20140311141220-0500"), 42 | ) 43 | 44 | def test_tz(self): 45 | self.assertEqual("+0205", _UTCOffset(125).tzname(datetime.utcnow())) 46 | self.assertEqual("-0410", _UTCOffset(-250).tzname(datetime.utcnow())) 47 | 48 | def test_parse_tzname(self): 49 | dt = parse_datetime("201403111412-0500") 50 | self.assertEqual("-0500", dt.tzname()) 51 | dt = parse_datetime("201403111412+0530") 52 | self.assertEqual("+0530", dt.tzname()) 53 | 54 | def test_utc_offset_float(self): 55 | self.assertEqual("-0500", _UTCOffset(-300.0).tzname(datetime.utcnow())) 56 | self.assertEqual("+0530", _UTCOffset(330.0).tzname(datetime.utcnow())) 57 | -------------------------------------------------------------------------------- /tests/test_mllp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import asyncio.streams 3 | from unittest import IsolatedAsyncioTestCase 4 | from unittest.mock import create_autospec 5 | 6 | import hl7 7 | import hl7.mllp 8 | 9 | START_BLOCK = b"\x0b" 10 | END_BLOCK = b"\x1c" 11 | CARRIAGE_RETURN = b"\x0d" 12 | 13 | 14 | class MLLPStreamWriterTest(IsolatedAsyncioTestCase): 15 | def setUp(self): 16 | self.transport = create_autospec(asyncio.Transport) 17 | 18 | async def asyncSetUp(self): 19 | self.writer = hl7.mllp.MLLPStreamWriter( 20 | self.transport, 21 | create_autospec(asyncio.streams.StreamReaderProtocol), 22 | create_autospec(hl7.mllp.MLLPStreamReader), 23 | asyncio.get_running_loop(), 24 | ) 25 | 26 | def test_writeblock(self): 27 | self.writer.writeblock(b"foobar") 28 | self.transport.write.assert_called_with( 29 | START_BLOCK + b"foobar" + END_BLOCK + CARRIAGE_RETURN 30 | ) 31 | 32 | 33 | class MLLPStreamReaderTest(IsolatedAsyncioTestCase): 34 | def setUp(self): 35 | self.reader = hl7.mllp.MLLPStreamReader() 36 | 37 | async def test_readblock(self): 38 | self.reader.feed_data(START_BLOCK + b"foobar" + END_BLOCK + CARRIAGE_RETURN) 39 | block = await self.reader.readblock() 40 | self.assertEqual(block, b"foobar") 41 | 42 | 43 | class HL7StreamWriterTest(IsolatedAsyncioTestCase): 44 | def setUp(self): 45 | self.transport = create_autospec(asyncio.Transport) 46 | 47 | async def asyncSetUp(self): 48 | def mock_cb(reader, writer): 49 | pass 50 | 51 | reader = create_autospec(asyncio.streams.StreamReader) 52 | 53 | self.writer = hl7.mllp.HL7StreamWriter( 54 | self.transport, 55 | hl7.mllp.HL7StreamProtocol(reader, mock_cb, asyncio.get_running_loop()), 56 | reader, 57 | asyncio.get_running_loop(), 58 | ) 59 | 60 | def test_writemessage(self): 61 | message = r"MSH|^~\&|LABADT|DH|EPICADT|DH|201301011228||ACK^A01^ACK|HL7ACK00001|P|2.3\r" 62 | message += "MSA|AA|HL7MSG00001\r" 63 | hl7_message = hl7.parse(message) 64 | self.writer.writemessage(hl7_message) 65 | self.transport.write.assert_called_with( 66 | START_BLOCK + message.encode() + END_BLOCK + CARRIAGE_RETURN 67 | ) 68 | 69 | 70 | class HL7StreamReaderTest(IsolatedAsyncioTestCase): 71 | def setUp(self): 72 | self.reader = hl7.mllp.HL7StreamReader() 73 | 74 | async def test_readblock(self): 75 | message = r"MSH|^~\&|LABADT|DH|EPICADT|DH|201301011228||ACK^A01^ACK|HL7ACK00001|P|2.3\r" 76 | message += "MSA|AA|HL7MSG00001\r" 77 | self.reader.feed_data( 78 | START_BLOCK + message.encode() + END_BLOCK + CARRIAGE_RETURN 79 | ) 80 | hl7_message = await self.reader.readmessage() 81 | self.assertEqual(str(hl7_message), str(hl7.parse(message))) 82 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import hl7 4 | from hl7 import Accessor, Component, Field, Message, ParseException, Repetition, Segment 5 | 6 | from .samples import ( 7 | rep_sample_hl7, 8 | sample_bad_batch, 9 | sample_bad_batch1, 10 | sample_bad_file, 11 | sample_bad_file1, 12 | sample_bad_file2, 13 | sample_bad_file3, 14 | sample_batch, 15 | sample_batch1, 16 | sample_batch2, 17 | sample_batch3, 18 | sample_batch4, 19 | sample_file, 20 | sample_file1, 21 | sample_file2, 22 | sample_file3, 23 | sample_file4, 24 | sample_file5, 25 | sample_file6, 26 | sample_hl7, 27 | ) 28 | 29 | 30 | class ParseTest(TestCase): 31 | def test_parse(self): 32 | msg = hl7.parse(sample_hl7) 33 | self.assertEqual(len(msg), 5) 34 | self.assertIsInstance(msg[0][0][0], str) 35 | self.assertEqual(msg[0][0][0], "MSH") 36 | self.assertEqual(msg[3][0][0], "OBX") 37 | self.assertEqual( 38 | msg[3][3], 39 | [[["1554-5"], ["GLUCOSE"], ["POST 12H CFST:MCNC:PT:SER/PLAS:QN"]]], 40 | ) 41 | # Make sure MSH-1 and MSH-2 are valid 42 | self.assertEqual(msg[0][1][0], "|") 43 | self.assertIsInstance(msg[0][1], hl7.Field) 44 | self.assertEqual(msg[0][2][0], r"^~\&") 45 | self.assertIsInstance(msg[0][2], hl7.Field) 46 | # MSH-9 is the message type 47 | self.assertEqual(msg[0][9], [[["ORU"], ["R01"]]]) 48 | # Do it twice to make sure text coercion is idempotent 49 | self.assertEqual(str(msg), sample_hl7) 50 | self.assertEqual(str(msg), sample_hl7) 51 | 52 | def test_parse_batch(self): 53 | batch = hl7.parse_batch(sample_batch) 54 | self.assertEqual(len(batch), 1) 55 | self.assertIsInstance(batch[0], hl7.Message) 56 | self.assertIsInstance(batch.header, hl7.Segment) 57 | self.assertEqual(batch.header[0][0], "BHS") 58 | self.assertEqual(batch.header[4][0], "ABCHS") 59 | self.assertIsInstance(batch.trailer, hl7.Segment) 60 | self.assertEqual(batch.trailer[0][0], "BTS") 61 | self.assertEqual(batch.trailer[1][0], "1") 62 | 63 | def test_parse_batch1(self): 64 | batch = hl7.parse_batch(sample_batch1) 65 | self.assertEqual(len(batch), 2) 66 | self.assertIsInstance(batch[0], hl7.Message) 67 | self.assertEqual(batch[0][0][10][0], "12334456778890") 68 | self.assertIsInstance(batch[1], hl7.Message) 69 | self.assertEqual(batch[1][0][10][0], "12334456778891") 70 | self.assertIsInstance(batch.header, hl7.Segment) 71 | self.assertEqual(batch.header[0][0], "BHS") 72 | self.assertEqual(batch.header[4][0], "ABCHS") 73 | self.assertIsInstance(batch.trailer, hl7.Segment) 74 | self.assertEqual(batch.trailer[0][0], "BTS") 75 | self.assertEqual(batch.trailer[1][0], "2") 76 | 77 | def test_parse_batch2(self): 78 | batch = hl7.parse_batch(sample_batch2) 79 | self.assertEqual(len(batch), 2) 80 | self.assertIsInstance(batch[0], hl7.Message) 81 | self.assertEqual(batch[0][0][10][0], "12334456778890") 82 | self.assertIsInstance(batch[1], hl7.Message) 83 | self.assertEqual(batch[1][0][10][0], "12334456778891") 84 | self.assertFalse(batch.header) 85 | self.assertFalse(batch.trailer) 86 | 87 | def test_parse_batch3(self): 88 | batch = hl7.parse_batch(sample_batch3) 89 | self.assertEqual(len(batch), 1) 90 | self.assertIsInstance(batch[0], hl7.Message) 91 | self.assertIsInstance(batch.header, hl7.Segment) 92 | self.assertEqual(batch.header[0][0], "BHS") 93 | self.assertEqual(batch.header[4][0], "ABCHS") 94 | self.assertIsInstance(batch.trailer, hl7.Segment) 95 | self.assertEqual(batch.trailer[0][0], "BTS") 96 | 97 | def test_parse_batch4(self): 98 | batch = hl7.parse_batch(sample_batch4) 99 | self.assertEqual(len(batch), 1) 100 | self.assertIsInstance(batch[0], hl7.Message) 101 | self.assertIsNone(batch.header) 102 | self.assertIsNone(batch.trailer) 103 | 104 | def test_parse_bad_batch(self): 105 | with self.assertRaises(ParseException) as cm: 106 | hl7.parse_batch(sample_bad_batch) 107 | self.assertIn("Segment received before message header", cm.exception.args[0]) 108 | 109 | def test_parse_bad_batch1(self): 110 | with self.assertRaises(ParseException) as cm: 111 | hl7.parse_batch(sample_bad_batch1) 112 | self.assertIn( 113 | "Batch cannot have more than one BHS segment", cm.exception.args[0] 114 | ) 115 | 116 | def test_parse_file(self): 117 | file = hl7.parse_file(sample_file) 118 | self.assertEqual(len(file), 1) 119 | self.assertIsInstance(file[0], hl7.Batch) 120 | self.assertIsInstance(file.header, hl7.Segment) 121 | self.assertEqual(file.header[0][0], "FHS") 122 | self.assertEqual(file.header[4][0], "ABCHS") 123 | self.assertIsInstance(file.trailer, hl7.Segment) 124 | self.assertEqual(file.trailer[0][0], "FTS") 125 | self.assertEqual(file.trailer[1][0], "1") 126 | 127 | def test_parse_file1(self): 128 | file = hl7.parse_file(sample_file1) 129 | self.assertEqual(len(file), 2) 130 | self.assertIsInstance(file[0], hl7.Batch) 131 | self.assertEqual(file[0].trailer[1][0], "2") 132 | self.assertIsInstance(file[1], hl7.Batch) 133 | self.assertEqual(file[1].trailer[1][0], "1") 134 | self.assertNotEqual(file[0], file[1]) 135 | self.assertIsInstance(file.header, hl7.Segment) 136 | self.assertEqual(file.header[0][0], "FHS") 137 | self.assertEqual(file.header[4][0], "ABCHS") 138 | self.assertIsInstance(file.trailer, hl7.Segment) 139 | self.assertEqual(file.trailer[0][0], "FTS") 140 | self.assertEqual(file.trailer[1][0], "2") 141 | 142 | def test_parse_file2(self): 143 | file = hl7.parse_file(sample_file2) 144 | self.assertEqual(len(file), 1) 145 | self.assertIsInstance(file[0], hl7.Batch) 146 | self.assertIsInstance(file.header, hl7.Segment) 147 | self.assertEqual(file.header[0][0], "FHS") 148 | self.assertEqual(file.header[4][0], "ABCHS") 149 | self.assertIsInstance(file.trailer, hl7.Segment) 150 | self.assertEqual(file.trailer[0][0], "FTS") 151 | self.assertEqual(file.trailer[1][0], "1") 152 | 153 | def test_parse_file3(self): 154 | file = hl7.parse_file(sample_file3) 155 | self.assertEqual(len(file), 1) 156 | self.assertIsInstance(file[0], hl7.Batch) 157 | self.assertIsInstance(file.header, hl7.Segment) 158 | self.assertEqual(file.header[0][0], "FHS") 159 | self.assertEqual(file.header[4][0], "ABCHS") 160 | self.assertIsInstance(file.trailer, hl7.Segment) 161 | self.assertEqual(file.trailer[0][0], "FTS") 162 | 163 | def test_parse_file4(self): 164 | file = hl7.parse_file(sample_file4) 165 | self.assertEqual(len(file), 1) 166 | self.assertIsInstance(file[0], hl7.Batch) 167 | self.assertIsNone(file.header) 168 | self.assertIsNone(file.trailer) 169 | 170 | def test_parse_file5(self): 171 | file = hl7.parse_file(sample_file5) 172 | self.assertEqual(len(file), 1) 173 | self.assertIsInstance(file[0], hl7.Batch) 174 | self.assertIsInstance(file.header, hl7.Segment) 175 | self.assertEqual(file.header[0][0], "FHS") 176 | self.assertEqual(file.header[4][0], "ABCHS") 177 | self.assertIsInstance(file.trailer, hl7.Segment) 178 | self.assertEqual(file.trailer[0][0], "FTS") 179 | self.assertEqual(file.trailer[1][0], "1") 180 | 181 | def test_parse_file6(self): 182 | file = hl7.parse_file(sample_file6) 183 | self.assertEqual(len(file), 1) 184 | self.assertIsInstance(file[0], hl7.Batch) 185 | self.assertIsInstance(file.header, hl7.Segment) 186 | self.assertEqual(file.header[0][0], "FHS") 187 | self.assertEqual(file.header[4][0], "ABCHS") 188 | self.assertIsInstance(file.trailer, hl7.Segment) 189 | self.assertEqual(file.trailer[0][0], "FTS") 190 | self.assertEqual(file.trailer[1][0], "1") 191 | 192 | def test_parse_bad_file(self): 193 | with self.assertRaises(ParseException) as cm: 194 | hl7.parse_file(sample_bad_file) 195 | self.assertIn("Segment received before message header", cm.exception.args[0]) 196 | 197 | def test_parse_bad_file1(self): 198 | with self.assertRaises(ParseException) as cm: 199 | hl7.parse_file(sample_bad_file1) 200 | self.assertIn( 201 | "Batch cannot have more than one BHS segment", cm.exception.args[0] 202 | ) 203 | 204 | def test_parse_bad_file2(self): 205 | with self.assertRaises(ParseException) as cm: 206 | hl7.parse_file(sample_bad_file2) 207 | self.assertIn( 208 | "File cannot have more than one FHS segment", cm.exception.args[0] 209 | ) 210 | 211 | def test_parse_bad_file3(self): 212 | with self.assertRaises(ParseException) as cm: 213 | hl7.parse_file(sample_bad_file3) 214 | self.assertIn("Segment received before message header", cm.exception.args[0]) 215 | 216 | def test_parse_hl7(self): 217 | obj = hl7.parse_hl7(sample_hl7) 218 | self.assertIsInstance(obj, hl7.Message) 219 | obj = hl7.parse_hl7(sample_batch) 220 | self.assertIsInstance(obj, hl7.Batch) 221 | obj = hl7.parse_hl7(sample_batch1) 222 | self.assertIsInstance(obj, hl7.Batch) 223 | obj = hl7.parse_hl7(sample_batch2) 224 | self.assertIsInstance(obj, hl7.Batch) 225 | obj = hl7.parse_hl7(sample_file) 226 | self.assertIsInstance(obj, hl7.File) 227 | obj = hl7.parse_hl7(sample_file1) 228 | self.assertIsInstance(obj, hl7.File) 229 | obj = hl7.parse_hl7(sample_file2) 230 | self.assertIsInstance(obj, hl7.File) 231 | 232 | def test_bytestring_converted_to_unicode(self): 233 | msg = hl7.parse(str(sample_hl7)) 234 | self.assertEqual(len(msg), 5) 235 | self.assertIsInstance(msg[0][0][0], str) 236 | self.assertEqual(msg[0][0][0], "MSH") 237 | 238 | def test_non_ascii_bytestring(self): 239 | # \x96 - valid cp1252, not valid utf8 240 | # it is the responsibility of the caller to convert to unicode 241 | msg = hl7.parse(b"MSH|^~\\&|GHH LAB|ELAB\x963", encoding="cp1252") 242 | self.assertEqual(msg[0][4][0], "ELAB\u20133") 243 | 244 | def test_non_ascii_bytestring_no_encoding(self): 245 | # \x96 - valid cp1252, not valid utf8 246 | # it is the responsibility of the caller to convert to unicode 247 | self.assertRaises(UnicodeDecodeError, hl7.parse, b"MSH|^~\\&|GHH LAB|ELAB\x963") 248 | 249 | def test_parsing_classes(self): 250 | msg = hl7.parse(sample_hl7) 251 | 252 | self.assertIsInstance(msg, hl7.Message) 253 | self.assertIsInstance(msg[3], hl7.Segment) 254 | self.assertIsInstance(msg[3][0], hl7.Field) 255 | self.assertIsInstance(msg[3][0][0], str) 256 | 257 | def test_nonstandard_separators(self): 258 | nonstd = "MSH$%~\\&$GHH LAB\rPID$$$555-44-4444$$EVERYWOMAN%EVE%E%%%L\r" 259 | msg = hl7.parse(nonstd) 260 | 261 | self.assertEqual(str(msg), nonstd) 262 | self.assertEqual(len(msg), 2) 263 | self.assertEqual( 264 | msg[1][5], [[["EVERYWOMAN"], ["EVE"], ["E"], [""], [""], ["L"]]] 265 | ) 266 | 267 | def test_repetition(self): 268 | msg = hl7.parse(rep_sample_hl7) 269 | self.assertEqual(msg[1][4], [["Repeat1"], ["Repeat2"]]) 270 | self.assertIsInstance(msg[1][4], Field) 271 | self.assertIsInstance(msg[1][4][0], Repetition) 272 | self.assertIsInstance(msg[1][4][1], Repetition) 273 | self.assertEqual(str(msg[1][4][0][0]), "Repeat1") 274 | self.assertIsInstance(msg[1][4][0][0], str) 275 | self.assertEqual(str(msg[1][4][1][0]), "Repeat2") 276 | self.assertIsInstance(msg[1][4][1][0], str) 277 | 278 | def test_empty_initial_repetition(self): 279 | # Switch to look like "|~Repeat2| 280 | msg = hl7.parse(rep_sample_hl7.replace("Repeat1", "")) 281 | self.assertEqual(msg[1][4], [[""], ["Repeat2"]]) 282 | 283 | def test_subcomponent(self): 284 | msg = hl7.parse(rep_sample_hl7) 285 | self.assertEqual( 286 | msg[1][3], 287 | [[["Component1"], ["Sub-Component1", "Sub-Component2"], ["Component3"]]], 288 | ) 289 | 290 | def test_elementnumbering(self): 291 | # Make sure that the numbering of repetitions. components and 292 | # sub-components is indexed from 1 when invoked as callable 293 | # (for compatibility with HL7 spec numbering) 294 | # and not 0-based (default for Python list) 295 | msg = hl7.parse(rep_sample_hl7) 296 | f = msg(2)(3)(1)(2)(2) 297 | self.assertIs(f, msg["PID.3.1.2.2"]) 298 | self.assertIs(f, msg[1][3][0][1][1]) 299 | f = msg(2)(4)(2)(1) 300 | self.assertIs(f, msg["PID.4.2.1"]) 301 | self.assertIs(f, msg[1][4][1][0]) 302 | # Repetition level accessed in list-form doesn't make much sense... 303 | self.assertIs(f, msg["PID.4.2"]) 304 | 305 | def test_extract(self): 306 | msg = hl7.parse(rep_sample_hl7) 307 | 308 | # Full correct path 309 | self.assertEqual(msg["PID.3.1.2.2"], "Sub-Component2") 310 | self.assertEqual(msg[Accessor("PID", 1, 3, 1, 2, 2)], "Sub-Component2") 311 | 312 | # Shorter Paths 313 | self.assertEqual(msg["PID.1.1"], "Field1") 314 | self.assertEqual(msg[Accessor("PID", 1, 1, 1)], "Field1") 315 | self.assertEqual(msg["PID.1"], "Field1") 316 | self.assertEqual(msg["PID1.1"], "Field1") 317 | self.assertEqual(msg["PID.3.1.2"], "Sub-Component1") 318 | 319 | # Longer Paths 320 | self.assertEqual(msg["PID.1.1.1.1"], "Field1") 321 | 322 | # Incorrect path 323 | self.assertRaisesRegex( 324 | IndexError, 325 | "PID.1.1.1.2", 326 | msg.extract_field, 327 | *Accessor.parse_key("PID.1.1.1.2"), 328 | ) 329 | 330 | # Optional field, not included in message 331 | self.assertEqual(msg["MSH.20"], "") 332 | 333 | # Optional sub-component, not included in message 334 | self.assertEqual(msg["PID.3.1.2.3"], "") 335 | self.assertEqual(msg["PID.3.1.3"], "Component3") 336 | self.assertEqual(msg["PID.3.1.4"], "") 337 | 338 | def test_assign(self): 339 | msg = hl7.parse(rep_sample_hl7) 340 | 341 | # Field 342 | msg["MSH.20"] = "FIELD 20" 343 | self.assertEqual(msg["MSH.20"], "FIELD 20") 344 | 345 | # Component 346 | msg["MSH.21.1.1"] = "COMPONENT 21.1.1" 347 | self.assertEqual(msg["MSH.21.1.1"], "COMPONENT 21.1.1") 348 | 349 | # Sub-Component 350 | msg["MSH.21.1.2.4"] = "SUBCOMPONENT 21.1.2.4" 351 | self.assertEqual(msg["MSH.21.1.2.4"], "SUBCOMPONENT 21.1.2.4") 352 | 353 | # Verify round-tripping (i.e. that separators are correct) 354 | msg2 = hl7.parse(str(msg)) 355 | self.assertEqual(msg2["MSH.20"], "FIELD 20") 356 | self.assertEqual(msg2["MSH.21.1.1"], "COMPONENT 21.1.1") 357 | self.assertEqual(msg2["MSH.21.1.2.4"], "SUBCOMPONENT 21.1.2.4") 358 | 359 | def test_unescape(self): 360 | msg = hl7.parse(rep_sample_hl7) 361 | 362 | # Replace Separators 363 | self.assertEqual(msg.unescape("\\E\\"), "\\") 364 | self.assertEqual(msg.unescape("\\F\\"), "|") 365 | self.assertEqual(msg.unescape("\\S\\"), "^") 366 | self.assertEqual(msg.unescape("\\T\\"), "&") 367 | self.assertEqual(msg.unescape("\\R\\"), "~") 368 | 369 | # Replace Highlighting 370 | self.assertEqual(msg.unescape("\\H\\text\\N\\"), "_text_") 371 | 372 | # Application Overrides 373 | self.assertEqual(msg.unescape("\\H\\text\\N\\", {"H": "*", "N": "*"}), "*text*") 374 | 375 | # Hex Codes 376 | self.assertEqual(msg.unescape("\\X20202020\\"), " ") 377 | self.assertEqual(msg.unescape("\\Xe1\\\\Xe9\\\\Xed\\\\Xf3\\\\Xfa\\"), "áéíóú") 378 | 379 | def test_escape(self): 380 | msg = hl7.parse(rep_sample_hl7) 381 | 382 | # Escape Separators 383 | self.assertEqual(msg.escape("\\"), "\\E\\") 384 | self.assertEqual(msg.escape("|"), "\\F\\") 385 | self.assertEqual(msg.escape("^"), "\\S\\") 386 | self.assertEqual(msg.escape("&"), "\\T\\") 387 | self.assertEqual(msg.escape("~"), "\\R\\") 388 | 389 | # Escape ASCII characters 390 | self.assertEqual(msg.escape("asdf"), "asdf") 391 | 392 | # Escape non-ASCII characters 393 | self.assertEqual(msg.escape("áéíóú"), "\\Xe1\\\\Xe9\\\\Xed\\\\Xf3\\\\Xfa\\") 394 | self.assertEqual(msg.escape("äsdf"), "\\Xe4\\sdf") 395 | 396 | def test_file(self): 397 | # Extract message from file 398 | self.assertTrue(hl7.isfile(sample_file)) 399 | messages = hl7.split_file(sample_file) 400 | self.assertEqual(len(messages), 1) 401 | 402 | # message can be parsed 403 | msg = hl7.parse(messages[0]) 404 | 405 | # message has expected content 406 | self.assertEqual( 407 | [s[0][0] for s in msg], ["MSH", "EVN", "PID", "PD1", "NK1", "PV1"] 408 | ) 409 | 410 | 411 | class ParsePlanTest(TestCase): 412 | def test_create_parse_plan(self): 413 | plan = hl7.parser.create_parse_plan(sample_hl7) 414 | 415 | self.assertEqual(plan.separators, "\r|~^&") 416 | self.assertEqual( 417 | plan.containers, [Message, Segment, Field, Repetition, Component] 418 | ) 419 | 420 | def test_parse_plan(self): 421 | plan = hl7.parser.create_parse_plan(sample_hl7) 422 | 423 | self.assertEqual(plan.separator, "\r") 424 | con = plan.container([1, 2]) 425 | self.assertIsInstance(con, Message) 426 | self.assertEqual(con, [1, 2]) 427 | self.assertEqual(con.separator, "\r") 428 | 429 | def test_parse_plan_next(self): 430 | plan = hl7.parser.create_parse_plan(sample_hl7) 431 | 432 | n1 = plan.next() 433 | self.assertEqual(n1.separators, "\r|~^&") 434 | self.assertEqual(n1.separator, "|") 435 | self.assertEqual(n1.containers, [Segment, Field, Repetition, Component]) 436 | 437 | n2 = n1.next() 438 | self.assertEqual(n2.separators, "\r|~^&") 439 | self.assertEqual(n2.separator, "~") 440 | self.assertEqual(n2.containers, [Field, Repetition, Component]) 441 | 442 | n3 = n2.next() 443 | self.assertEqual(n3.separators, "\r|~^&") 444 | self.assertEqual(n3.separator, "^") 445 | self.assertEqual(n3.containers, [Repetition, Component]) 446 | 447 | n4 = n3.next() 448 | self.assertEqual(n4.separators, "\r|~^&") 449 | self.assertEqual(n4.separator, "&") 450 | self.assertEqual(n4.containers, [Component]) 451 | 452 | n5 = n4.next() 453 | self.assertTrue(n5 is None) 454 | 455 | def test_create_parse_plan_invalid_segment(self): 456 | with self.assertRaises(ParseException) as cm: 457 | hl7.parser.create_parse_plan("PID|^~\\&|GHH LAB") 458 | self.assertIn("must be one of MSH, FHS or BHS", cm.exception.args[0]) 459 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import hl7 4 | 5 | from .samples import ( 6 | sample_batch, 7 | sample_batch1, 8 | sample_batch2, 9 | sample_file, 10 | sample_file1, 11 | sample_file2, 12 | sample_hl7, 13 | sample_msh, 14 | ) 15 | 16 | 17 | class IsHL7Test(TestCase): 18 | def test_ishl7(self): 19 | self.assertTrue(hl7.ishl7(sample_hl7)) 20 | self.assertFalse(hl7.ishl7(sample_batch)) 21 | self.assertFalse(hl7.ishl7(sample_batch1)) 22 | self.assertFalse(hl7.ishl7(sample_batch2)) 23 | self.assertFalse(hl7.ishl7(sample_file)) 24 | self.assertFalse(hl7.ishl7(sample_file1)) 25 | self.assertFalse(hl7.ishl7(sample_file2)) 26 | self.assertTrue(hl7.ishl7(sample_msh)) 27 | 28 | def test_ishl7_empty(self): 29 | self.assertFalse(hl7.ishl7("")) 30 | 31 | def test_ishl7_None(self): 32 | self.assertFalse(hl7.ishl7(None)) 33 | 34 | def test_ishl7_wrongsegment(self): 35 | message = "OBX|1|SN|1554-5^GLUCOSE^POST 12H CFST:MCNC:PT:SER/PLAS:QN||^182|mg/dl|70_105|H|||F\r" 36 | self.assertFalse(hl7.ishl7(message)) 37 | 38 | def test_isbatch(self): 39 | self.assertFalse(hl7.ishl7(sample_batch)) 40 | self.assertFalse(hl7.ishl7(sample_batch1)) 41 | self.assertFalse(hl7.ishl7(sample_batch2)) 42 | self.assertTrue(hl7.isbatch(sample_batch)) 43 | self.assertTrue(hl7.isbatch(sample_batch1)) 44 | self.assertTrue(hl7.isbatch(sample_batch2)) 45 | 46 | def test_isfile(self): 47 | self.assertFalse(hl7.ishl7(sample_file)) 48 | self.assertFalse(hl7.ishl7(sample_file1)) 49 | self.assertFalse(hl7.ishl7(sample_file2)) 50 | self.assertFalse(hl7.isbatch(sample_file)) 51 | self.assertFalse(hl7.isbatch(sample_file1)) 52 | self.assertFalse(hl7.isbatch(sample_file2)) 53 | self.assertTrue(hl7.isfile(sample_file)) 54 | self.assertTrue(hl7.isfile(sample_file1)) 55 | self.assertTrue(hl7.isfile(sample_file2)) 56 | self.assertTrue(hl7.isfile(sample_batch)) 57 | self.assertTrue(hl7.isfile(sample_batch1)) 58 | self.assertTrue(hl7.isfile(sample_batch2)) 59 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py39, py310, py311, py312, py313, docs 4 | requires = 5 | tox-uv>=1.26 6 | 7 | [testenv] 8 | deps = 9 | .[dev] 10 | commands = 11 | python -m unittest discover -t . -s tests 12 | 13 | [testenv:py39] 14 | basepython = python3.9 15 | 16 | [testenv:py310] 17 | basepython = python3.10 18 | 19 | [testenv:py311] 20 | basepython = python3.11 21 | 22 | [testenv:py312] 23 | basepython = python3.12 24 | 25 | [testenv:py313] 26 | basepython = python3.13 27 | 28 | [testenv:docs] 29 | allowlist_externals = make 30 | deps = 31 | .[dev] 32 | commands = 33 | make clean-docs docs 34 | --------------------------------------------------------------------------------