├── .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 |
--------------------------------------------------------------------------------