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.
Files changed (36) hide show
  1. prelude_parser-0.2.0/.github/dependabot.yml +23 -0
  2. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/testing.yml +2 -2
  3. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.pre-commit-config.yaml +2 -2
  4. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/CONTRIBUTING.md +12 -27
  5. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/Cargo.lock +1 -1
  6. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/Cargo.toml +2 -1
  7. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/PKG-INFO +36 -3
  8. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/README.md +19 -1
  9. prelude_parser-0.2.0/justfile +40 -0
  10. prelude_parser-0.2.0/prelude_parser/__init__.py +3 -0
  11. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/prelude_parser/_prelude_parser.pyi +2 -1
  12. prelude_parser-0.2.0/prelude_parser/pandas.py +30 -0
  13. prelude_parser-0.2.0/prelude_parser/parser.py +77 -0
  14. prelude_parser-0.2.0/prelude_parser/polars.py +30 -0
  15. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/pyproject.toml +14 -1
  16. prelude_parser-0.2.0/requirements-dev.txt +10 -0
  17. prelude_parser-0.2.0/src/lib.rs +178 -0
  18. prelude_parser-0.2.0/tests/test_pandas.py +26 -0
  19. prelude_parser-0.2.0/tests/test_parser.py +226 -0
  20. prelude_parser-0.2.0/tests/test_polars.py +27 -0
  21. prelude_parser-0.1.0/prelude_parser/__init__.py +0 -3
  22. prelude_parser-0.1.0/prelude_parser/parser.py +0 -61
  23. prelude_parser-0.1.0/requirements-dev.txt +0 -6
  24. prelude_parser-0.1.0/src/lib.rs +0 -95
  25. prelude_parser-0.1.0/tests/test_parser.py +0 -70
  26. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/release-draft-template.yml +0 -0
  27. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/pypi_publish.yml +0 -0
  28. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.github/workflows/release-drafter.yml +0 -0
  29. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/.gitignore +0 -0
  30. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/LICENSE +0 -0
  31. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/prelude_parser/py.typed +0 -0
  32. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/__init__.py +0 -0
  33. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test1.xml +0 -0
  34. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test2.xml +0 -0
  35. {prelude_parser-0.1.0 → prelude_parser-0.2.0}/tests/assets/test3.xml +0 -0
  36. {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.2.1
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.2.1
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.2.0
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.263
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 parse_flat_file
18
+ from prelude_parser import parse_to_classes
19
19
 
20
- data = parse_flat_file("my_file.xml")
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
- ### Working with the code
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
- pip install -r requirements-dev.txt
56
+ just install
59
57
  ```
60
58
 
61
- Now the Rust module can be built in dev mode.
59
+ When changes are made to the Rust code, build and install the new module with:
62
60
 
63
61
  ```sh
64
- maturin develop
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
- # Run ruff
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
- pytest
153
+ just test
169
154
  ```
170
155
 
171
156
  In additon to mainting the coverage percentage please ensure that all tests are passing before
@@ -82,7 +82,7 @@ dependencies = [
82
82
 
83
83
  [[package]]
84
84
  name = "prelude-parser"
85
- version = "0.1.0"
85
+ version = "0.2.0"
86
86
  dependencies = [
87
87
  "pyo3",
88
88
  "roxmltree",
@@ -1,6 +1,7 @@
1
1
  [package]
2
2
  name = "prelude-parser"
3
- version = "0.1.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.1.0
3
+ Version: 0.2.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
- Requires-Dist: camel-converter>=3.0.0
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
  [![PyPI version](https://badge.fury.io/py/prelude-parser.svg)](https://badge.fury.io/py/prelude-parser)
21
36
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/prelude-parser?color=5cc141)](https://github.com/pbs-data-solutions/prelude-parser)
22
37
 
23
- Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Python classes.
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
  [![PyPI version](https://badge.fury.io/py/prelude-parser.svg)](https://badge.fury.io/py/prelude-parser)
7
7
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/prelude-parser?color=5cc141)](https://github.com/pbs-data-solutions/prelude-parser)
8
8
 
9
- Parses XML files exported from [Prelude EDC](https://preludeedc.com/) into Python classes.
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
@@ -0,0 +1,3 @@
1
+ from prelude_parser.parser import parse_to_classes, parse_to_dict
2
+
3
+ __all__ = ["parse_to_classes", "parse_to_dict"]
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
- def _parse_flat_file(xml_file: str | Path) -> dict[str, list[Any]]: ...
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.14,<0.15"]
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,10 @@
1
+ black==23.3.0
2
+ maturin==0.15.2
3
+ mypy==1.3.0
4
+ pandas-stubs==2.0.1.230501
5
+ pre-commit==3.3.2
6
+ pandas
7
+ polars
8
+ pytest==7.3.1
9
+ pytest-cov==4.0.0
10
+ ruff==0.0.269
@@ -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,3 +0,0 @@
1
- from prelude_parser.parser import parse_flat_file
2
-
3
- __all__ = ["parse_flat_file"]
@@ -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
@@ -1,6 +0,0 @@
1
- black==23.3.0
2
- maturin==0.14.17
3
- mypy==1.2.0
4
- pytest==7.3.1
5
- pytest-cov==4.0.0
6
- ruff==0.0.263
@@ -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