prelude-parser 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prelude_parser-0.2.0/.github/dependabot.yml +23 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/testing.yml +2 -2
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.pre-commit-config.yaml +2 -2
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/CONTRIBUTING.md +12 -27
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/Cargo.lock +1 -1
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/Cargo.toml +2 -1
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/PKG-INFO +36 -3
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/README.md +19 -1
- prelude_parser-0.2.0/justfile +40 -0
- prelude_parser-0.2.0/prelude_parser/__init__.py +3 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/prelude_parser/_prelude_parser.pyi +2 -1
- prelude_parser-0.2.0/prelude_parser/pandas.py +30 -0
- prelude_parser-0.2.0/prelude_parser/parser.py +77 -0
- prelude_parser-0.2.0/prelude_parser/polars.py +30 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/pyproject.toml +14 -1
- prelude_parser-0.2.0/requirements-dev.txt +10 -0
- prelude_parser-0.2.0/src/lib.rs +178 -0
- prelude_parser-0.2.0/tests/test_pandas.py +26 -0
- prelude_parser-0.2.0/tests/test_parser.py +226 -0
- prelude_parser-0.2.0/tests/test_polars.py +27 -0
- prelude_parser-0.1.0/prelude_parser/__init__.py +0 -3
- prelude_parser-0.1.0/prelude_parser/parser.py +0 -61
- prelude_parser-0.1.0/requirements-dev.txt +0 -6
- prelude_parser-0.1.0/src/lib.rs +0 -95
- prelude_parser-0.1.0/tests/test_parser.py +0 -70
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/release-draft-template.yml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/pypi_publish.yml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/release-drafter.yml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.gitignore +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/LICENSE +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/prelude_parser/py.typed +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/__init__.py +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test1.xml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test2.xml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test3.xml +0 -0
- {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/conftest.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "pip"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "daily"
|
|
7
|
+
labels:
|
|
8
|
+
- skip-changelog
|
|
9
|
+
- dependencies
|
|
10
|
+
- package-ecosystem: cargo
|
|
11
|
+
directory: "/"
|
|
12
|
+
schedule:
|
|
13
|
+
interval: daily
|
|
14
|
+
labels:
|
|
15
|
+
- skip-changelog
|
|
16
|
+
- dependencies
|
|
17
|
+
- package-ecosystem: github-actions
|
|
18
|
+
directory: '/'
|
|
19
|
+
schedule:
|
|
20
|
+
interval: daily
|
|
21
|
+
labels:
|
|
22
|
+
- skip-changelog
|
|
23
|
+
- dependencies
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
override: true
|
|
22
22
|
components: clippy
|
|
23
23
|
- name: Cache dependencies
|
|
24
|
-
uses: Swatinem/rust-cache@v2.
|
|
24
|
+
uses: Swatinem/rust-cache@v2.3.0
|
|
25
25
|
- name: Run cargo clippy
|
|
26
26
|
uses: actions-rs/cargo@v1
|
|
27
27
|
with:
|
|
@@ -39,7 +39,7 @@ jobs:
|
|
|
39
39
|
override: true
|
|
40
40
|
components: rustfmt
|
|
41
41
|
- name: Cache dependencies
|
|
42
|
-
uses: Swatinem/rust-cache@v2.
|
|
42
|
+
uses: Swatinem/rust-cache@v2.3.0
|
|
43
43
|
- name: Run cargo fmt
|
|
44
44
|
run: cargo fmt --all -- --check
|
|
45
45
|
test:
|
|
@@ -15,11 +15,11 @@ repos:
|
|
|
15
15
|
language_version: python3
|
|
16
16
|
args: [--line-length=100]
|
|
17
17
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
18
|
-
rev: v1.
|
|
18
|
+
rev: v1.3.0
|
|
19
19
|
hooks:
|
|
20
20
|
- id: mypy
|
|
21
21
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
|
22
|
-
rev: v0.0.
|
|
22
|
+
rev: v0.0.267
|
|
23
23
|
hooks:
|
|
24
24
|
- id: ruff
|
|
25
25
|
args: [--fix, --exit-non-zero-on-fix]
|
|
@@ -15,15 +15,18 @@ Please include:
|
|
|
15
15
|
[GitHub markdown](https://docs.github.com/en/free-pro-team@latest/github/writing-on-github). For example:
|
|
16
16
|
|
|
17
17
|
```py
|
|
18
|
-
from prelude_parser import
|
|
18
|
+
from prelude_parser import parse_to_classes
|
|
19
19
|
|
|
20
|
-
data =
|
|
20
|
+
data = parse_to_classes("my_file.xml")
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
2. Explain what is currently happening and what you expect instead.
|
|
24
24
|
|
|
25
25
|
## Working on the code
|
|
26
26
|
|
|
27
|
+
In order to work on this project you will need to have [Rust](https://www.rust-lang.org/) and
|
|
28
|
+
[just](https://github.com/casey/just) in addition to Python.
|
|
29
|
+
|
|
27
30
|
### Fork the project
|
|
28
31
|
|
|
29
32
|
In order to work on the project you will need your own fork. To do this click the "Fork" button on
|
|
@@ -39,12 +42,7 @@ git remote add upstream git@github.com:pbs-data-solutions/prelude-parser
|
|
|
39
42
|
|
|
40
43
|
This creates the directory prelude-parser and connects your repository to the upstream (main project) repository.
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
In order to work on this project you will need to have [Rust](https://www.rust-lang.org/) in
|
|
45
|
-
addition to Python.
|
|
46
|
-
|
|
47
|
-
Once Rust is installed you should create a vitural environment and activate it.
|
|
45
|
+
Next create a vitural environment and activate it.
|
|
48
46
|
|
|
49
47
|
```sh
|
|
50
48
|
python -m venv .venv
|
|
@@ -52,16 +50,16 @@ python -m venv .venv
|
|
|
52
50
|
. .venv/bin/activate
|
|
53
51
|
```
|
|
54
52
|
|
|
55
|
-
Then install the dev requirements
|
|
53
|
+
Then install the dev requirements. This will also build the Rust module in dev mode and install it.
|
|
56
54
|
|
|
57
55
|
```sh
|
|
58
|
-
|
|
56
|
+
just install
|
|
59
57
|
```
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
When changes are made to the Rust code, build and install the new module with:
|
|
62
60
|
|
|
63
61
|
```sh
|
|
64
|
-
|
|
62
|
+
just develop
|
|
65
63
|
```
|
|
66
64
|
|
|
67
65
|
### Creating a branch
|
|
@@ -95,20 +93,7 @@ formmating.
|
|
|
95
93
|
You can run linting on your code at any time with:
|
|
96
94
|
|
|
97
95
|
```sh
|
|
98
|
-
|
|
99
|
-
ruff check .
|
|
100
|
-
|
|
101
|
-
# Run black
|
|
102
|
-
black prelude_parser tests
|
|
103
|
-
|
|
104
|
-
# Run mypy
|
|
105
|
-
mypy .
|
|
106
|
-
|
|
107
|
-
# clippy
|
|
108
|
-
cargo clippy
|
|
109
|
-
|
|
110
|
-
# rustfmt
|
|
111
|
-
cargo fmt
|
|
96
|
+
just lint
|
|
112
97
|
```
|
|
113
98
|
|
|
114
99
|
It is also suggested that you setup [pre-commit](https://pre-commit.com/) in order to run linting
|
|
@@ -165,7 +150,7 @@ be accepted. You can view the current coverage level in the codecov badge on the
|
|
|
165
150
|
the code coverage by running:
|
|
166
151
|
|
|
167
152
|
```sh
|
|
168
|
-
|
|
153
|
+
just test
|
|
169
154
|
```
|
|
170
155
|
|
|
171
156
|
In additon to mainting the coverage percentage please ensure that all tests are passing before
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "prelude-parser"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Parses XML files exported from Prelude EDC into formats usable by Python."
|
|
4
5
|
edition = "2021"
|
|
5
6
|
license = "MIT"
|
|
6
7
|
homepage = "https://github.com/pbs-data-solutions/prelude-parser"
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prelude-parser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
-
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Dist: camel-converter >=3.0.0
|
|
13
|
+
Requires-Dist: pandas >=2.0.1 ; extra == 'pandas'
|
|
14
|
+
Requires-Dist: polars >=0.17.14 ; extra == 'polars'
|
|
15
|
+
Requires-Dist: pandas >=2.0.1 ; extra == 'all'
|
|
16
|
+
Requires-Dist: polars >=0.17.14 ; extra == 'all'
|
|
17
|
+
Provides-Extra: pandas
|
|
18
|
+
Provides-Extra: polars
|
|
19
|
+
Provides-Extra: all
|
|
7
20
|
License-File: LICENSE
|
|
21
|
+
Summary: Parses XML files exported from Prelude EDC into formats usable by Python.
|
|
22
|
+
Keywords: parser,prelude-edc,xml,pandas,polars
|
|
8
23
|
Home-Page: https://github.com/pbs-data-solutions/prelude-parser
|
|
9
24
|
Author-email: Paul Sanders <paul@pbsdatasolutions.com>
|
|
10
25
|
License: MIT
|
|
@@ -20,7 +35,7 @@ Project-URL: Source Code, https://github.com/pbs-data-solutions/prelude-parser
|
|
|
20
35
|
[](https://badge.fury.io/py/prelude-parser)
|
|
21
36
|
[](https://github.com/pbs-data-solutions/prelude-parser)
|
|
22
37
|
|
|
23
|
-
Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Python
|
|
38
|
+
Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into formats Python can use.
|
|
24
39
|
|
|
25
40
|
## Installation
|
|
26
41
|
|
|
@@ -28,6 +43,24 @@ Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Pytho
|
|
|
28
43
|
pip install prelude-parser
|
|
29
44
|
```
|
|
30
45
|
|
|
46
|
+
Optionally the `pandas` extra can be installed to parse to a Pandas `DataFrame`
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install prelude-parser[pandas]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Optionally the `polars` extra can be installed to parse to a Polars `DataFrame`
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pip install prelude-parser[polars]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
All extras can be install with
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pip install prelude-parser[all]
|
|
62
|
+
```
|
|
63
|
+
|
|
31
64
|
## Contributing
|
|
32
65
|
|
|
33
66
|
Contributions to this project are welcome. If you are interesting in contributing please see our [contributing guide](CONTRIBUTING.md)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://badge.fury.io/py/prelude-parser)
|
|
7
7
|
[](https://github.com/pbs-data-solutions/prelude-parser)
|
|
8
8
|
|
|
9
|
-
Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Python
|
|
9
|
+
Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into formats Python can use.
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
@@ -14,6 +14,24 @@ Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Pytho
|
|
|
14
14
|
pip install prelude-parser
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Optionally the `pandas` extra can be installed to parse to a Pandas `DataFrame`
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install prelude-parser[pandas]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Optionally the `polars` extra can be installed to parse to a Polars `DataFrame`
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install prelude-parser[polars]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
All extras can be install with
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install prelude-parser[all]
|
|
33
|
+
```
|
|
34
|
+
|
|
17
35
|
## Contributing
|
|
18
36
|
|
|
19
37
|
Contributions to this project are welcome. If you are interesting in contributing please see our [contributing guide](CONTRIBUTING.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
@develop:
|
|
2
|
+
maturin develop
|
|
3
|
+
|
|
4
|
+
@install: develop
|
|
5
|
+
pip install -r requirements-dev.txt
|
|
6
|
+
|
|
7
|
+
@lint:
|
|
8
|
+
echo cargo check
|
|
9
|
+
just --justfile {{justfile()}} check
|
|
10
|
+
echo cargo clippy
|
|
11
|
+
just --justfile {{justfile()}} clippy
|
|
12
|
+
echo cargo fmt
|
|
13
|
+
just --justfile {{justfile()}} fmt
|
|
14
|
+
echo ruff
|
|
15
|
+
just --justfile {{justfile()}} ruff
|
|
16
|
+
echo black
|
|
17
|
+
just --justfile {{justfile()}} black
|
|
18
|
+
echo mypy
|
|
19
|
+
just --justfile {{justfile()}} mypy
|
|
20
|
+
|
|
21
|
+
@check:
|
|
22
|
+
cargo check
|
|
23
|
+
|
|
24
|
+
@clippy:
|
|
25
|
+
cargo clippy
|
|
26
|
+
|
|
27
|
+
@fmt:
|
|
28
|
+
cargo fmt
|
|
29
|
+
|
|
30
|
+
@black:
|
|
31
|
+
black prelude_parser tests
|
|
32
|
+
|
|
33
|
+
@mypy:
|
|
34
|
+
mypy .
|
|
35
|
+
|
|
36
|
+
@ruff:
|
|
37
|
+
ruff check . --fix
|
|
38
|
+
|
|
39
|
+
@test:
|
|
40
|
+
pytest
|
|
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
def
|
|
6
|
+
def _parse_flat_file_to_dict(xml_file: str | Path) -> dict[str, list[dict[str, Any]]]: ...
|
|
7
|
+
def _parse_flat_file_to_pandas_dict(xml_file: str | Path) -> dict[str, list[Any]]: ...
|
|
7
8
|
|
|
8
9
|
class FileNotFoundError(Exception):
|
|
9
10
|
pass
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from prelude_parser._prelude_parser import _parse_flat_file_to_pandas_dict
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import pandas as pd
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
raise ImportError("prelude-parser must be installed with the pandas or all extra to use pandas")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_dataframe(xml_file: str | Path) -> pd.DataFrame:
|
|
14
|
+
"""Parse a Prelude flat XML file into a Pandas DataFrame.
|
|
15
|
+
|
|
16
|
+
This works for Prelude flat XML files that were exported with the "write tables to seperate
|
|
17
|
+
files" option.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
xml_file: The path to the XML file to parser.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A Pandas DataFrame the data from the XML file.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> from prelude_parser.pandas import to_dataframe
|
|
27
|
+
>>> df = to_dataframe("physical_examination.xml")
|
|
28
|
+
"""
|
|
29
|
+
data = _parse_flat_file_to_pandas_dict(xml_file)
|
|
30
|
+
return pd.DataFrame.from_dict(data)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from camel_converter import to_pascal
|
|
8
|
+
|
|
9
|
+
from prelude_parser._prelude_parser import _parse_flat_file_to_dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _MetaCls(type):
|
|
13
|
+
def __new__(
|
|
14
|
+
cls, clsname: str, superclasses: tuple[type, ...], attributedict: dict[str, Any]
|
|
15
|
+
) -> _MetaCls:
|
|
16
|
+
return super(_MetaCls, cls).__new__(cls, clsname, superclasses, attributedict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_to_dict(xml_file: str | Path) -> dict[str, list[dict[str, Any]]]:
|
|
20
|
+
"""Parse a Prelude flat XML file into a dict.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
xml_file: The path to the XML file to parser.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A Python dictionary containing the data from the XML file.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> from prelude_parser import parse_to_dict
|
|
30
|
+
>>> data = parse_to_dict("physical_examination.xml")
|
|
31
|
+
"""
|
|
32
|
+
parsed = _parse_flat_file_to_dict(xml_file)
|
|
33
|
+
for _, data in parsed.items():
|
|
34
|
+
for d in data:
|
|
35
|
+
for k, v in d.items():
|
|
36
|
+
try:
|
|
37
|
+
if "." in v:
|
|
38
|
+
d[k] = float(v)
|
|
39
|
+
else:
|
|
40
|
+
d[k] = int(v)
|
|
41
|
+
continue
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
d[k] = datetime.strptime(v, "%d-%b-%Y")
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return parsed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_to_classes(xml_file: str | Path) -> list[Any]:
|
|
54
|
+
"""Parse a Prelude flat XML file into a list of Python class.
|
|
55
|
+
|
|
56
|
+
The name of the class is taken from the form name node in the XML file converted to pascal case.
|
|
57
|
+
For example a <physical_examination> node will result in a PhysicalExamination class being
|
|
58
|
+
created.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
xml_file: The path to the XML file to parser.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A list of Python classes containing the data from the XML file.
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> from prelude_parser import parse_to_classes
|
|
68
|
+
>>> data = parse_to_classes("physical_examination.xml")
|
|
69
|
+
"""
|
|
70
|
+
parsed = parse_to_dict(xml_file)
|
|
71
|
+
formatted: list[Any] = []
|
|
72
|
+
for form, data in parsed.items():
|
|
73
|
+
class_name = to_pascal(form)
|
|
74
|
+
for d in data:
|
|
75
|
+
formatted.append(_MetaCls(class_name, (object,), d))
|
|
76
|
+
|
|
77
|
+
return formatted
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from prelude_parser._prelude_parser import _parse_flat_file_to_pandas_dict
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import polars as pl
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
raise ImportError("prelude-parser must be installed with the polars or all extra to use pandas")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_dataframe(xml_file: str | Path) -> pl.DataFrame:
|
|
14
|
+
"""Parse a Prelude flat XML file into a Polars DataFrame.
|
|
15
|
+
|
|
16
|
+
This works for Prelude flat XML files that were exported with the "write tables to seperate
|
|
17
|
+
files" option.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
xml_file: The path to the XML file to parser.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A Polars DataFrame the data from the XML file.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> from prelude_parser.polars import to_dataframe
|
|
27
|
+
>>> df = to_dataframe("physical_examination.xml")
|
|
28
|
+
"""
|
|
29
|
+
data = _parse_flat_file_to_pandas_dict(xml_file)
|
|
30
|
+
return pl.from_dict(data)
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["maturin>=0.
|
|
2
|
+
requires = ["maturin>=0.15"]
|
|
3
3
|
build-backend = "maturin"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prelude-parser"
|
|
7
7
|
requires-python = ">=3.8"
|
|
8
|
+
description = "Parses XML files exported from Prelude EDC into formats usable by Python."
|
|
8
9
|
authors = [{name = "Paul Sanders", email = "paul@pbsdatasolutions.com"}]
|
|
10
|
+
keywords = ["parser", "prelude-edc", "xml", "pandas", "polars"]
|
|
9
11
|
classifiers = [
|
|
10
12
|
"Programming Language :: Rust",
|
|
11
13
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
14
|
+
"Programming Language :: Python :: 3.8",
|
|
15
|
+
"Programming Language :: Python :: 3.9",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Typing :: Typed",
|
|
12
20
|
]
|
|
13
21
|
license = {file = "LICENSE"}
|
|
14
22
|
readme = "README.md"
|
|
@@ -17,6 +25,11 @@ homepage = "https://github.com/pbs-data-solutions/prelude-parser"
|
|
|
17
25
|
documentation = "https://github.com/pbs-data-solutions/prelude-parser"
|
|
18
26
|
dependencies = ["camel-converter>=3.0.0"]
|
|
19
27
|
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
pandas = ["pandas>=2.0.1"]
|
|
30
|
+
polars = ["polars>=0.17.14"]
|
|
31
|
+
all = ["pandas>=2.0.1", "polars>=0.17.14"]
|
|
32
|
+
|
|
20
33
|
[tool.maturin]
|
|
21
34
|
module-name = "prelude_parser._prelude_parser"
|
|
22
35
|
binding = "pyo3"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::fs::read_to_string;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
|
|
5
|
+
use pyo3::create_exception;
|
|
6
|
+
use pyo3::exceptions::PyException;
|
|
7
|
+
use pyo3::prelude::*;
|
|
8
|
+
use pyo3::types::{IntoPyDict, PyDict};
|
|
9
|
+
use roxmltree::Document;
|
|
10
|
+
|
|
11
|
+
create_exception!(_prelude_parser, FileNotFoundError, PyException);
|
|
12
|
+
create_exception!(_prelude_parser, InvalidFileTypeError, PyException);
|
|
13
|
+
create_exception!(_prelude_parser, ParsingError, PyException);
|
|
14
|
+
|
|
15
|
+
fn to_snake(camel_string: &str) -> String {
|
|
16
|
+
let snake_string: String = camel_string
|
|
17
|
+
.chars()
|
|
18
|
+
.map(|c| {
|
|
19
|
+
if c.is_uppercase() {
|
|
20
|
+
format!("_{}", c)
|
|
21
|
+
} else {
|
|
22
|
+
c.to_string()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.collect();
|
|
26
|
+
|
|
27
|
+
// There is some weirdness in the Prelude XML files that can result in names like
|
|
28
|
+
// `i_communications_Details`. Because of this we can end up with values like
|
|
29
|
+
// `i_communications__details` so we need to fix double `__` values when we return the string.
|
|
30
|
+
|
|
31
|
+
snake_string
|
|
32
|
+
.trim_start_matches('_')
|
|
33
|
+
.to_lowercase()
|
|
34
|
+
.replace("__", "_")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn parse_xml<'py>(py: Python<'py>, xml_file: &PathBuf) -> PyResult<&'py PyDict> {
|
|
38
|
+
let reader = read_to_string(xml_file);
|
|
39
|
+
|
|
40
|
+
match reader {
|
|
41
|
+
Ok(r) => match Document::parse(&r) {
|
|
42
|
+
Ok(doc) => {
|
|
43
|
+
let mut data: HashMap<String, Vec<&PyDict>> = HashMap::new();
|
|
44
|
+
let tree = doc.root_element();
|
|
45
|
+
for form in tree.children() {
|
|
46
|
+
let form_name = to_snake(form.tag_name().name());
|
|
47
|
+
if !form_name.is_empty() {
|
|
48
|
+
if let Some(d) = data.get_mut(&form_name) {
|
|
49
|
+
let mut form_data: HashMap<String, Option<&str>> = HashMap::new();
|
|
50
|
+
for child in form.children() {
|
|
51
|
+
if child.is_element() && child.tag_name().name() != "" {
|
|
52
|
+
let key = to_snake(child.tag_name().name());
|
|
53
|
+
form_data.insert(key, child.text());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
d.push(form_data.into_py_dict(py));
|
|
57
|
+
} else {
|
|
58
|
+
let mut items: Vec<&PyDict> = Vec::new();
|
|
59
|
+
let mut form_data: HashMap<String, Option<&str>> = HashMap::new();
|
|
60
|
+
for child in form.children() {
|
|
61
|
+
if child.is_element() && child.tag_name().name() != "" {
|
|
62
|
+
let key = to_snake(child.tag_name().name());
|
|
63
|
+
form_data.insert(key, child.text());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
items.push(form_data.into_py_dict(py));
|
|
67
|
+
data.insert(form_name, items);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Ok(data.into_py_dict(py));
|
|
72
|
+
}
|
|
73
|
+
Err(e) => Err(ParsingError::new_err(format!(
|
|
74
|
+
"Error parsing xml file: {:?}",
|
|
75
|
+
e
|
|
76
|
+
))),
|
|
77
|
+
},
|
|
78
|
+
Err(e) => Err(ParsingError::new_err(format!(
|
|
79
|
+
"Error parsing xml file: {:?}",
|
|
80
|
+
e
|
|
81
|
+
))),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn parse_xml_pandas<'py>(py: Python<'py>, xml_file: &PathBuf) -> PyResult<&'py PyDict> {
|
|
86
|
+
let reader = read_to_string(xml_file);
|
|
87
|
+
|
|
88
|
+
match reader {
|
|
89
|
+
Ok(r) => match Document::parse(&r) {
|
|
90
|
+
Ok(doc) => {
|
|
91
|
+
let mut data: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
|
|
92
|
+
let tree = doc.root_element();
|
|
93
|
+
|
|
94
|
+
for form in tree.children() {
|
|
95
|
+
for child in form.children() {
|
|
96
|
+
if child.is_element() && child.tag_name().name() != "" {
|
|
97
|
+
let column = child.tag_name().name();
|
|
98
|
+
if let Some(d) = data.get_mut(column) {
|
|
99
|
+
d.push(child.text());
|
|
100
|
+
} else {
|
|
101
|
+
data.insert(column, vec![child.text()]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Ok(data.into_py_dict(py));
|
|
107
|
+
}
|
|
108
|
+
Err(e) => Err(ParsingError::new_err(format!(
|
|
109
|
+
"Error parsing xml file: {:?}",
|
|
110
|
+
e
|
|
111
|
+
))),
|
|
112
|
+
},
|
|
113
|
+
Err(e) => Err(ParsingError::new_err(format!(
|
|
114
|
+
"Error parsing xml file: {:?}",
|
|
115
|
+
e
|
|
116
|
+
))),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn validate_file(xml_file: &PathBuf) -> PyResult<()> {
|
|
121
|
+
if !xml_file.is_file() {
|
|
122
|
+
return Err(FileNotFoundError::new_err(format!(
|
|
123
|
+
"File not found: {:?}",
|
|
124
|
+
xml_file
|
|
125
|
+
)));
|
|
126
|
+
} else if xml_file.extension().unwrap() != "xml" {
|
|
127
|
+
return Err(InvalidFileTypeError::new_err(format!(
|
|
128
|
+
"{:?} is not an xml file",
|
|
129
|
+
xml_file
|
|
130
|
+
)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
Ok(())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[pyfunction]
|
|
137
|
+
fn _parse_flat_file_to_dict(py: Python, xml_file: PathBuf) -> PyResult<&PyDict> {
|
|
138
|
+
validate_file(&xml_file)?;
|
|
139
|
+
let data = parse_xml(py, &xml_file)?;
|
|
140
|
+
|
|
141
|
+
Ok(data)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[pyfunction]
|
|
145
|
+
fn _parse_flat_file_to_pandas_dict(py: Python, xml_file: PathBuf) -> PyResult<&PyDict> {
|
|
146
|
+
validate_file(&xml_file)?;
|
|
147
|
+
let data = parse_xml_pandas(py, &xml_file)?;
|
|
148
|
+
|
|
149
|
+
Ok(data)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[pymodule]
|
|
153
|
+
fn _prelude_parser(py: Python, m: &PyModule) -> PyResult<()> {
|
|
154
|
+
m.add_function(wrap_pyfunction!(_parse_flat_file_to_dict, m)?)?;
|
|
155
|
+
m.add_function(wrap_pyfunction!(_parse_flat_file_to_pandas_dict, m)?)?;
|
|
156
|
+
m.add("FileNotFoundError", py.get_type::<FileNotFoundError>())?;
|
|
157
|
+
m.add(
|
|
158
|
+
"InvalidFileTypeError",
|
|
159
|
+
py.get_type::<InvalidFileTypeError>(),
|
|
160
|
+
)?;
|
|
161
|
+
m.add("ParsingError", py.get_type::<ParsingError>())?;
|
|
162
|
+
Ok(())
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#[cfg(test)]
|
|
166
|
+
mod tests {
|
|
167
|
+
use std::assert_eq;
|
|
168
|
+
|
|
169
|
+
use super::*;
|
|
170
|
+
|
|
171
|
+
#[test]
|
|
172
|
+
fn test_to_snake() {
|
|
173
|
+
assert_eq!(
|
|
174
|
+
to_snake("i_communications_Details"),
|
|
175
|
+
String::from("i_communications_details")
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from prelude_parser.pandas import to_dataframe
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_pandas_to_dataframe(test_file_1):
|
|
7
|
+
result = to_dataframe(test_file_1)
|
|
8
|
+
data = {
|
|
9
|
+
"BaseForm": [
|
|
10
|
+
"communications.form.name.communications",
|
|
11
|
+
"communications.form.name.communications",
|
|
12
|
+
],
|
|
13
|
+
"FormGroup": ["Communications", "Communications"],
|
|
14
|
+
"FormNumber": [None, None],
|
|
15
|
+
"FormState": ["In-Work", "In-Work"],
|
|
16
|
+
"FormTitle": ["Communications", "Communications"],
|
|
17
|
+
"PatientId": ["1681574905819", "1681574994823"],
|
|
18
|
+
"PatientName": ["ABC-001", "ABC-002"],
|
|
19
|
+
"SiteId": ["1681574834910", "1681574834910"],
|
|
20
|
+
"SiteName": ["Some Site", "Some Site"],
|
|
21
|
+
"StudyName": ["PBS", "PBS"],
|
|
22
|
+
"communications_made": ["Yes", "Yes"],
|
|
23
|
+
}
|
|
24
|
+
expected = pd.DataFrame.from_dict(data)
|
|
25
|
+
result = result.reindex(sorted(result.columns), axis=1)
|
|
26
|
+
assert expected.equals(result)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from prelude_parser import parse_to_classes, parse_to_dict
|
|
6
|
+
from prelude_parser._prelude_parser import FileNotFoundError, InvalidFileTypeError, ParsingError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_parse_to_classes(test_file_1):
|
|
10
|
+
result = parse_to_classes(test_file_1)
|
|
11
|
+
assert len(result) == 2
|
|
12
|
+
assert result[0].__name__ == "Communications"
|
|
13
|
+
assert result[0].study_name == "PBS"
|
|
14
|
+
assert result[0].site_name == "Some Site"
|
|
15
|
+
assert result[0].site_id == 1681574834910
|
|
16
|
+
assert result[0].patient_name == "ABC-001"
|
|
17
|
+
assert result[0].patient_id == 1681574905819
|
|
18
|
+
assert result[0].form_title == "Communications"
|
|
19
|
+
assert result[0].base_form == "communications.form.name.communications"
|
|
20
|
+
assert result[0].form_number is None
|
|
21
|
+
assert result[0].form_group == "Communications"
|
|
22
|
+
assert result[0].form_state == "In-Work"
|
|
23
|
+
assert result[0].communications_made == "Yes"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_parse_to_classes_with_float(test_file_2):
|
|
27
|
+
result = parse_to_classes(test_file_2)
|
|
28
|
+
assert len(result) == 2
|
|
29
|
+
assert result[0].__name__ == "Demographics"
|
|
30
|
+
assert result[0].weight == 80.2
|
|
31
|
+
assert result[0].dob == datetime(2020, 4, 15)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_parse_to_classes_i_form(test_file_3):
|
|
35
|
+
result = parse_to_classes(test_file_3)
|
|
36
|
+
assert len(result) == 3
|
|
37
|
+
assert result[0].__name__ == "ICommunicationsDetails"
|
|
38
|
+
assert result[0].study_name == "PBS"
|
|
39
|
+
assert result[0].site_name == "Some Site"
|
|
40
|
+
assert result[0].site_id == 1681574834910
|
|
41
|
+
assert result[0].patient_name == "ABC-001"
|
|
42
|
+
assert result[0].patient_id == 1681574905819
|
|
43
|
+
assert result[0].form_title == "Communications"
|
|
44
|
+
assert result[0].base_form == "communications.form.name.communications"
|
|
45
|
+
assert result[0].form_number is None
|
|
46
|
+
assert result[0].form_group == "Communications"
|
|
47
|
+
assert result[0].form_state == "In-Work"
|
|
48
|
+
assert result[0].i == 1
|
|
49
|
+
assert result[0].contacted_by == "You"
|
|
50
|
+
assert result[0].investigator == "Dr. Me"
|
|
51
|
+
assert result[0].communication == "Some random talk"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_parse_to_classes_not_found_error():
|
|
55
|
+
with pytest.raises(FileNotFoundError):
|
|
56
|
+
parse_to_classes("bad.xml")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_parse_to_classes_invalid_file_type_error(tmp_path):
|
|
60
|
+
bad = tmp_path / "bad.txt"
|
|
61
|
+
bad.touch()
|
|
62
|
+
with pytest.raises(InvalidFileTypeError):
|
|
63
|
+
parse_to_classes(bad)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_parse_to_classes_parsing_error(tmp_path):
|
|
67
|
+
bad = tmp_path / "bad.xml"
|
|
68
|
+
bad.touch()
|
|
69
|
+
with pytest.raises(ParsingError):
|
|
70
|
+
parse_to_classes(bad)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_parse_to_dict(test_file_1):
|
|
74
|
+
result = parse_to_dict(test_file_1)
|
|
75
|
+
expected = {
|
|
76
|
+
"communications": [
|
|
77
|
+
{
|
|
78
|
+
"base_form": "communications.form.name.communications",
|
|
79
|
+
"communications_made": "Yes",
|
|
80
|
+
"form_group": "Communications",
|
|
81
|
+
"form_number": None,
|
|
82
|
+
"form_state": "In-Work",
|
|
83
|
+
"form_title": "Communications",
|
|
84
|
+
"patient_id": 1681574905819,
|
|
85
|
+
"patient_name": "ABC-001",
|
|
86
|
+
"site_id": 1681574834910,
|
|
87
|
+
"site_name": "Some Site",
|
|
88
|
+
"study_name": "PBS",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"base_form": "communications.form.name.communications",
|
|
92
|
+
"communications_made": "Yes",
|
|
93
|
+
"form_group": "Communications",
|
|
94
|
+
"form_number": None,
|
|
95
|
+
"form_state": "In-Work",
|
|
96
|
+
"form_title": "Communications",
|
|
97
|
+
"patient_id": 1681574994823,
|
|
98
|
+
"patient_name": "ABC-002",
|
|
99
|
+
"site_id": 1681574834910,
|
|
100
|
+
"site_name": "Some Site",
|
|
101
|
+
"study_name": "PBS",
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
result["communications"] = [dict(sorted(x.items())) for x in result["communications"]]
|
|
107
|
+
|
|
108
|
+
assert result == expected
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_parse_to_dict_with_float(test_file_2):
|
|
112
|
+
result = parse_to_dict(test_file_2)
|
|
113
|
+
expected = {
|
|
114
|
+
"demographics": [
|
|
115
|
+
{
|
|
116
|
+
"base_form": "day.0.form.name.demographics",
|
|
117
|
+
"breed": "Labrador",
|
|
118
|
+
"dob": datetime(2020, 4, 15),
|
|
119
|
+
"first_name": "Imma",
|
|
120
|
+
"form_group": "Day 0",
|
|
121
|
+
"form_number": None,
|
|
122
|
+
"form_state": "In-Work",
|
|
123
|
+
"form_title": "Demographics",
|
|
124
|
+
"gender": "Female Spayed",
|
|
125
|
+
"last_name": "Dog",
|
|
126
|
+
"patient_id": 1681574905819,
|
|
127
|
+
"patient_name": "ABC-001",
|
|
128
|
+
"screening_number": 1,
|
|
129
|
+
"site_id": 1681574834910,
|
|
130
|
+
"site_name": "Some Site",
|
|
131
|
+
"site_type": "Live",
|
|
132
|
+
"study_name": "PBS",
|
|
133
|
+
"subject_id": "ABC-001",
|
|
134
|
+
"visit_date": datetime(2023, 4, 15),
|
|
135
|
+
"weight": 80.2,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"base_form": "day.0.form.name.demographics",
|
|
139
|
+
"breed": "Golden",
|
|
140
|
+
"dob": datetime(2019, 4, 9),
|
|
141
|
+
"first_name": "Arthur",
|
|
142
|
+
"form_group": "Day 0",
|
|
143
|
+
"form_number": None,
|
|
144
|
+
"form_state": "In-Work",
|
|
145
|
+
"form_title": "Demographics",
|
|
146
|
+
"gender": "Male Neutered",
|
|
147
|
+
"last_name": "Dent",
|
|
148
|
+
"patient_id": 1681574994823,
|
|
149
|
+
"patient_name": "ABC-002",
|
|
150
|
+
"screening_number": 2,
|
|
151
|
+
"site_id": 1681574834910,
|
|
152
|
+
"site_name": "Some Site",
|
|
153
|
+
"site_type": "Live",
|
|
154
|
+
"study_name": "PBS",
|
|
155
|
+
"subject_id": "ABC-002",
|
|
156
|
+
"visit_date": datetime(2023, 4, 15),
|
|
157
|
+
"weight": 40.5,
|
|
158
|
+
},
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result["demographics"] = [dict(sorted(x.items())) for x in result["demographics"]]
|
|
163
|
+
|
|
164
|
+
assert result == expected
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_parse_to_dict_i_form(test_file_3):
|
|
168
|
+
result = parse_to_dict(test_file_3)
|
|
169
|
+
expected = {
|
|
170
|
+
"i_communications_details": [
|
|
171
|
+
{
|
|
172
|
+
"base_form": "communications.form.name.communications",
|
|
173
|
+
"communication": "Some random talk",
|
|
174
|
+
"contacted_by": "You",
|
|
175
|
+
"form_group": "Communications",
|
|
176
|
+
"form_number": None,
|
|
177
|
+
"form_state": "In-Work",
|
|
178
|
+
"form_title": "Communications",
|
|
179
|
+
"i": 1,
|
|
180
|
+
"investigator": "Dr. Me",
|
|
181
|
+
"patient_id": 1681574905819,
|
|
182
|
+
"patient_name": "ABC-001",
|
|
183
|
+
"site_id": 1681574834910,
|
|
184
|
+
"site_name": "Some Site",
|
|
185
|
+
"study_name": "PBS",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"base_form": "communications.form.name.communications",
|
|
189
|
+
"communication": "We talked",
|
|
190
|
+
"contacted_by": "Hi",
|
|
191
|
+
"form_group": "Communications",
|
|
192
|
+
"form_number": None,
|
|
193
|
+
"form_state": "In-Work",
|
|
194
|
+
"form_title": "Communications",
|
|
195
|
+
"i": 1,
|
|
196
|
+
"investigator": "There",
|
|
197
|
+
"patient_id": 1681574994823,
|
|
198
|
+
"patient_name": "ABC-002",
|
|
199
|
+
"site_id": 1681574834910,
|
|
200
|
+
"site_name": "Some Site",
|
|
201
|
+
"study_name": "PBS",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"base_form": "communications.form.name.communications",
|
|
205
|
+
"communication": "We talked again",
|
|
206
|
+
"contacted_by": "There",
|
|
207
|
+
"i": 2,
|
|
208
|
+
"investigator": "Hi",
|
|
209
|
+
"form_group": "Communications",
|
|
210
|
+
"form_number": None,
|
|
211
|
+
"form_state": "In-Work",
|
|
212
|
+
"form_title": "Communications",
|
|
213
|
+
"patient_id": 1681574994823,
|
|
214
|
+
"patient_name": "ABC-002",
|
|
215
|
+
"site_id": 1681574834910,
|
|
216
|
+
"site_name": "Some Site",
|
|
217
|
+
"study_name": "PBS",
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result["i_communications_details"] = [
|
|
223
|
+
dict(sorted(x.items())) for x in result["i_communications_details"]
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
assert result == expected
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from polars.testing import assert_frame_equal
|
|
3
|
+
|
|
4
|
+
from prelude_parser.polars import to_dataframe
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_pandas_to_dataframe(test_file_1):
|
|
8
|
+
result = to_dataframe(test_file_1)
|
|
9
|
+
data = {
|
|
10
|
+
"BaseForm": [
|
|
11
|
+
"communications.form.name.communications",
|
|
12
|
+
"communications.form.name.communications",
|
|
13
|
+
],
|
|
14
|
+
"FormGroup": ["Communications", "Communications"],
|
|
15
|
+
"FormNumber": [None, None],
|
|
16
|
+
"FormState": ["In-Work", "In-Work"],
|
|
17
|
+
"FormTitle": ["Communications", "Communications"],
|
|
18
|
+
"PatientId": ["1681574905819", "1681574994823"],
|
|
19
|
+
"PatientName": ["ABC-001", "ABC-002"],
|
|
20
|
+
"SiteId": ["1681574834910", "1681574834910"],
|
|
21
|
+
"SiteName": ["Some Site", "Some Site"],
|
|
22
|
+
"StudyName": ["PBS", "PBS"],
|
|
23
|
+
"communications_made": ["Yes", "Yes"],
|
|
24
|
+
}
|
|
25
|
+
expected = pl.from_dict(data) # type: ignore
|
|
26
|
+
result = result.pipe(lambda x: x.select(sorted(x.columns)))
|
|
27
|
+
assert_frame_equal(expected, result)
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from camel_converter import to_pascal, to_snake
|
|
8
|
-
|
|
9
|
-
from prelude_parser._prelude_parser import _parse_flat_file
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class _MetaCls(type):
|
|
13
|
-
def __new__(cls, clsname: str, superclasses: tuple[type, ...], attributedict: dict) -> _MetaCls:
|
|
14
|
-
return super(_MetaCls, cls).__new__(cls, clsname, superclasses, attributedict)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def parse_flat_file(xml_file: str | Path) -> list[Any]:
|
|
18
|
-
"""Parse a Prelude flat XML file into a Python class.
|
|
19
|
-
|
|
20
|
-
The name of the class is taken from the form name node in the XML file converted to pascal case.
|
|
21
|
-
For example a <physical_examination> node will result in a PhysicalExamination class being
|
|
22
|
-
created.
|
|
23
|
-
|
|
24
|
-
Args:
|
|
25
|
-
xml_file: The path to the XML file to parser.
|
|
26
|
-
|
|
27
|
-
Returns:
|
|
28
|
-
A list of Python classes containing the data from the XML file.
|
|
29
|
-
|
|
30
|
-
Examples:
|
|
31
|
-
>>> from prelude_parser import parse_flat_file
|
|
32
|
-
>>> data = parse_flat_file("physical_examination.xml")
|
|
33
|
-
"""
|
|
34
|
-
parsed = _parse_flat_file(xml_file)
|
|
35
|
-
formatted: list[Any] = []
|
|
36
|
-
for form, data in parsed.items():
|
|
37
|
-
class_name = to_pascal(form)
|
|
38
|
-
formatted_data: dict[str, Any] = {}
|
|
39
|
-
|
|
40
|
-
for d in data:
|
|
41
|
-
for k, v in d.items():
|
|
42
|
-
key = to_snake(k)
|
|
43
|
-
try:
|
|
44
|
-
if "." in v:
|
|
45
|
-
formatted_data[key] = float(v)
|
|
46
|
-
else:
|
|
47
|
-
formatted_data[key] = int(v)
|
|
48
|
-
continue
|
|
49
|
-
except (TypeError, ValueError):
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
try:
|
|
53
|
-
formatted_data[key] = datetime.strptime(v, "%d-%b-%Y")
|
|
54
|
-
continue
|
|
55
|
-
except (TypeError, ValueError):
|
|
56
|
-
pass
|
|
57
|
-
|
|
58
|
-
formatted_data[key] = v
|
|
59
|
-
formatted.append(_MetaCls(class_name, (object,), formatted_data))
|
|
60
|
-
|
|
61
|
-
return formatted
|
prelude_parser-0.1.0/src/lib.rs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
use std::collections::HashMap;
|
|
2
|
-
use std::fs::read_to_string;
|
|
3
|
-
use std::path::PathBuf;
|
|
4
|
-
|
|
5
|
-
use pyo3::create_exception;
|
|
6
|
-
use pyo3::exceptions::PyException;
|
|
7
|
-
use pyo3::prelude::*;
|
|
8
|
-
use pyo3::types::{IntoPyDict, PyDict};
|
|
9
|
-
use roxmltree::Document;
|
|
10
|
-
|
|
11
|
-
create_exception!(_prelude_parser, FileNotFoundError, PyException);
|
|
12
|
-
create_exception!(_prelude_parser, InvalidFileTypeError, PyException);
|
|
13
|
-
create_exception!(_prelude_parser, ParsingError, PyException);
|
|
14
|
-
|
|
15
|
-
fn parse_xml<'py>(py: Python<'py>, xml_file: &PathBuf) -> PyResult<&'py PyDict> {
|
|
16
|
-
let reader = read_to_string(xml_file);
|
|
17
|
-
|
|
18
|
-
match reader {
|
|
19
|
-
Ok(r) => match Document::parse(&r) {
|
|
20
|
-
Ok(doc) => {
|
|
21
|
-
let mut data: HashMap<&str, Vec<&PyDict>> = HashMap::new();
|
|
22
|
-
let tree = doc.root_element();
|
|
23
|
-
for form in tree.children() {
|
|
24
|
-
let form_name = form.tag_name().name();
|
|
25
|
-
if !form_name.is_empty() {
|
|
26
|
-
if let Some(d) = data.get_mut(form_name) {
|
|
27
|
-
let mut form_data: HashMap<&str, Option<&str>> = HashMap::new();
|
|
28
|
-
for child in form.children() {
|
|
29
|
-
if child.is_element() && child.tag_name().name() != "" {
|
|
30
|
-
form_data.insert(child.tag_name().name(), child.text());
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
d.push(form_data.into_py_dict(py));
|
|
34
|
-
} else {
|
|
35
|
-
let mut items: Vec<&PyDict> = Vec::new();
|
|
36
|
-
let mut form_data: HashMap<&str, Option<&str>> = HashMap::new();
|
|
37
|
-
for child in form.children() {
|
|
38
|
-
if child.is_element() && child.tag_name().name() != "" {
|
|
39
|
-
form_data.insert(child.tag_name().name(), child.text());
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
items.push(form_data.into_py_dict(py));
|
|
43
|
-
data.insert(form_name, items);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return Ok(data.into_py_dict(py));
|
|
48
|
-
}
|
|
49
|
-
Err(e) => Err(ParsingError::new_err(format!(
|
|
50
|
-
"Error parsing xml file: {:?}",
|
|
51
|
-
e
|
|
52
|
-
))),
|
|
53
|
-
},
|
|
54
|
-
Err(e) => Err(ParsingError::new_err(format!(
|
|
55
|
-
"Error parsing xml file: {:?}",
|
|
56
|
-
e
|
|
57
|
-
))),
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
fn validate_file(xml_file: &PathBuf) -> PyResult<()> {
|
|
62
|
-
if !xml_file.is_file() {
|
|
63
|
-
return Err(FileNotFoundError::new_err(format!(
|
|
64
|
-
"File not found: {:?}",
|
|
65
|
-
xml_file
|
|
66
|
-
)));
|
|
67
|
-
} else if xml_file.extension().unwrap() != "xml" {
|
|
68
|
-
return Err(InvalidFileTypeError::new_err(format!(
|
|
69
|
-
"{:?} is not an xml file",
|
|
70
|
-
xml_file
|
|
71
|
-
)));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
Ok(())
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
#[pyfunction]
|
|
78
|
-
fn _parse_flat_file(py: Python, xml_file: PathBuf) -> PyResult<&PyDict> {
|
|
79
|
-
validate_file(&xml_file)?;
|
|
80
|
-
let data = parse_xml(py, &xml_file)?;
|
|
81
|
-
|
|
82
|
-
Ok(data)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
#[pymodule]
|
|
86
|
-
fn _prelude_parser(py: Python, m: &PyModule) -> PyResult<()> {
|
|
87
|
-
m.add_function(wrap_pyfunction!(_parse_flat_file, m)?)?;
|
|
88
|
-
m.add("FileNotFoundError", py.get_type::<FileNotFoundError>())?;
|
|
89
|
-
m.add(
|
|
90
|
-
"InvalidFileTypeError",
|
|
91
|
-
py.get_type::<InvalidFileTypeError>(),
|
|
92
|
-
)?;
|
|
93
|
-
m.add("ParsingError", py.get_type::<ParsingError>())?;
|
|
94
|
-
Ok(())
|
|
95
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from prelude_parser._prelude_parser import FileNotFoundError, InvalidFileTypeError, ParsingError
|
|
6
|
-
from prelude_parser.parser import parse_flat_file
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_parse_flat_file(test_file_1):
|
|
10
|
-
result = parse_flat_file(test_file_1)
|
|
11
|
-
assert len(result) == 2
|
|
12
|
-
assert result[0].__name__ == "Communications"
|
|
13
|
-
assert result[0].study_name == "PBS"
|
|
14
|
-
assert result[0].site_name == "Some Site"
|
|
15
|
-
assert result[0].site_id == 1681574834910
|
|
16
|
-
assert result[0].patient_name == "ABC-001"
|
|
17
|
-
assert result[0].patient_id == 1681574905819
|
|
18
|
-
assert result[0].form_title == "Communications"
|
|
19
|
-
assert result[0].base_form == "communications.form.name.communications"
|
|
20
|
-
assert result[0].form_number is None
|
|
21
|
-
assert result[0].form_group == "Communications"
|
|
22
|
-
assert result[0].form_state == "In-Work"
|
|
23
|
-
assert result[0].communications_made == "Yes"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_parse_flat_file_with_float(test_file_2):
|
|
27
|
-
result = parse_flat_file(test_file_2)
|
|
28
|
-
assert len(result) == 2
|
|
29
|
-
assert result[0].__name__ == "Demographics"
|
|
30
|
-
assert result[0].weight == 80.2
|
|
31
|
-
assert result[0].dob == datetime(2020, 4, 15)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_parse_flat_file_i_form(test_file_3):
|
|
35
|
-
result = parse_flat_file(test_file_3)
|
|
36
|
-
assert len(result) == 3
|
|
37
|
-
assert result[0].__name__ == "ICommunicationsDetails"
|
|
38
|
-
assert result[0].study_name == "PBS"
|
|
39
|
-
assert result[0].site_name == "Some Site"
|
|
40
|
-
assert result[0].site_id == 1681574834910
|
|
41
|
-
assert result[0].patient_name == "ABC-001"
|
|
42
|
-
assert result[0].patient_id == 1681574905819
|
|
43
|
-
assert result[0].form_title == "Communications"
|
|
44
|
-
assert result[0].base_form == "communications.form.name.communications"
|
|
45
|
-
assert result[0].form_number is None
|
|
46
|
-
assert result[0].form_group == "Communications"
|
|
47
|
-
assert result[0].form_state == "In-Work"
|
|
48
|
-
assert result[0].i == 1
|
|
49
|
-
assert result[0].contacted_by == "You"
|
|
50
|
-
assert result[0].investigator == "Dr. Me"
|
|
51
|
-
assert result[0].communication == "Some random talk"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_parse_flat_file_not_found_error():
|
|
55
|
-
with pytest.raises(FileNotFoundError):
|
|
56
|
-
parse_flat_file("bad.xml")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_parse_flat_file_invalid_file_type_error(tmp_path):
|
|
60
|
-
bad = tmp_path / "bad.txt"
|
|
61
|
-
bad.touch()
|
|
62
|
-
with pytest.raises(InvalidFileTypeError):
|
|
63
|
-
parse_flat_file(bad)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_parse_flat_file_parsing_error(tmp_path):
|
|
67
|
-
bad = tmp_path / "bad.xml"
|
|
68
|
-
bad.touch()
|
|
69
|
-
with pytest.raises(ParsingError):
|
|
70
|
-
parse_flat_file(bad)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|