raveforge 0.4.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.
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: raveforge
3
+ Version: 0.4.0
4
+ Summary: An elegant Python builder for Medidata Rave ODM XML.
5
+ Author: Mohamed Nouira
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/mnouira02/raveforge
8
+ Project-URL: Issues, https://github.com/mnouira02/raveforge/issues
9
+ Keywords: medidata,rave,odm,cdisc,clinical-trials,rws,xml
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: requests>=2.28
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+
31
+ # RaveForge
32
+
33
+ An elegant Python builder for Medidata Rave ODM XML.
34
+
35
+ Pushing clinical data, lab results, operational signals, or AI-derived outputs into Medidata Rave Web Services (RWS) often becomes a mess of fragile XML string concatenation, namespace issues, and hard-to-maintain hierarchy handling. RaveForge was built to make that process clean, explicit, and reliable.
36
+
37
+ RaveForge is a fluent, stateful Python engine for building RWS-friendly ODM XML without manually writing XML tags. It helps you construct properly nested `Subject > Event > Form > ItemGroup > Item` structures, optionally validates the transaction before serialisation, and optionally submits through a lightweight RWS client.
38
+
39
+ ---
40
+
41
+ ## Features
42
+
43
+ - Fluent builder API for CDISC ODM clinical data transactions
44
+ - Stateful hierarchy handling for `Subject > Event > Form > ItemGroup > Item`
45
+ - Early hierarchy validation with clear Python exceptions
46
+ - **Pre-build structural validation** with aggregated, location-aware error reporting
47
+ - Support for Medidata-specific `mdsol` extensions
48
+ - Query generation with configurable status and recipient
49
+ - Support for item `SpecifyValue`
50
+ - Pretty-printed XML output for debugging
51
+ - Thin RWS client for submission to Medidata RWS
52
+ - Read-only diagnostics layer for interpreting RWS submission failures
53
+ - Tested with `pytest`
54
+
55
+ ---
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install raveforge
61
+ ```
62
+
63
+ For development:
64
+
65
+ ```bash
66
+ pip install -e .[dev]
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Quick Start
72
+
73
+ ```python
74
+ from raveforge import RaveTransaction, ActionType
75
+
76
+ tx = (
77
+ RaveTransaction(study_oid="Oncology_Phase_II_Prod")
78
+ .subject("SUBJ-001", site_oid="SITE-101", action=ActionType.UPSERT)
79
+ .event("SCREENING", repeat_key="1")
80
+ .form("VS", repeat_key="1")
81
+ .item_group("VS_GROUP", repeat_key="1")
82
+ .item("VSTEST", "Weight")
83
+ .item("VSORRES", "75")
84
+ )
85
+
86
+ xml_payload = tx.build()
87
+ print(tx.build_pretty())
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Pre-build Validation
93
+
94
+ Call `validate()` before `build()` to catch structural and semantic problems early, with a single aggregated report instead of one exception at a time.
95
+
96
+ ```python
97
+ from raveforge import RaveTransaction, validate, ValidationError
98
+
99
+ tx = (
100
+ RaveTransaction(study_oid="Oncology_Phase_II_Prod")
101
+ .subject("SUBJ-001", site_oid="SITE-101")
102
+ .event("SCREENING")
103
+ .form("VS")
104
+ .item_group("VS_GROUP")
105
+ .item("VSORRES", "75")
106
+ )
107
+
108
+ try:
109
+ validate(tx) # raises ValidationError if anything is wrong
110
+ xml_payload = tx.build()
111
+ except ValidationError as err:
112
+ print(err)
113
+ ```
114
+
115
+ Sample output for an empty study OID and a subject with no events:
116
+
117
+ ```
118
+ 2 validation issue(s) found:
119
+ [ERROR] STUDY_OID_EMPTY []: study_oid must not be empty.
120
+ [WARNING] SUBJECT_NO_EVENTS [SUBJ-001]: Subject 'SUBJ-001' has no events. ...
121
+ ```
122
+
123
+ ### Strict vs. non-strict mode
124
+
125
+ By default `validate()` runs in **strict mode**: both `ERROR` and `WARNING`
126
+ severity issues raise `ValidationError`. Pass `strict=False` to raise only on
127
+ errors and receive warnings as return values:
128
+
129
+ ```python
130
+ from raveforge import validate, Severity
131
+
132
+ issues = validate(tx, strict=False) # never raises on WARNING
133
+ warnings = [i for i in issues if i.severity == Severity.WARNING]
134
+ for w in warnings:
135
+ print(w)
136
+ ```
137
+
138
+ ### What the validator checks
139
+
140
+ | Code | Severity | Trigger |
141
+ |---|---|---|
142
+ | `STUDY_OID_EMPTY` | ERROR | `study_oid` is blank or whitespace-only |
143
+ | `STUDY_OID_INVALID_CHARS` | ERROR | `study_oid` contains `<`, `>`, or `&` |
144
+ | `NO_SUBJECTS` | WARNING | Transaction has no subjects at all |
145
+ | `SUBJECT_NO_EVENTS` | WARNING | A subject has no events |
146
+ | `SITE_OID_EMPTY` | ERROR | A subject's `SiteOID` is blank |
147
+ | `EVENT_NO_FORMS` | WARNING | An event has no forms |
148
+ | `EVENT_OID_EMPTY` | ERROR | An event has a blank OID |
149
+ | `FORM_NO_ITEM_GROUPS` | WARNING | A form has no item groups |
150
+ | `FORM_OID_EMPTY` | ERROR | A form has a blank OID |
151
+ | `ITEM_GROUP_OID_EMPTY` | ERROR | An item group has a blank OID |
152
+ | `ITEM_NO_VALUE` | WARNING | An item has no value, specify, or query |
153
+ | `ITEM_OID_EMPTY` | ERROR | An item has a blank OID |
154
+
155
+ ---
156
+
157
+ ## Queries and Specify Values
158
+
159
+ ```python
160
+ from raveforge import (
161
+ RaveTransaction,
162
+ QueryStatus,
163
+ QueryRecipient,
164
+ )
165
+
166
+ tx = (
167
+ RaveTransaction("Mediflex_Study")
168
+ .subject("SUBJ-1001", "SITE-NL-01")
169
+ .event("VISIT_1")
170
+ .form("LABS")
171
+ .item_group("LABS_IG", specified_items_only=True)
172
+ .item(
173
+ "LBTEST",
174
+ value="OTHER",
175
+ specify="Custom Biomarker Panel",
176
+ query="Please confirm the local lab method.",
177
+ query_status=QueryStatus.OPEN,
178
+ query_recipient=QueryRecipient.SITE_FROM_DM,
179
+ )
180
+ )
181
+
182
+ xml_payload = tx.build()
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Submitting to RWS
188
+
189
+ ```python
190
+ from raveforge import RaveTransaction
191
+ from raveforge.rws_client import RWSClient
192
+
193
+ tx = (
194
+ RaveTransaction("Mediflex_Study")
195
+ .subject("SUBJ-001", "SITE-001")
196
+ .event("SCREENING")
197
+ .form("DM")
198
+ .item_group("DM_IG")
199
+ .item("AGE", "42")
200
+ )
201
+
202
+ client = RWSClient(
203
+ base_url="https://innovate.mdsol.com",
204
+ username="username",
205
+ password="password",
206
+ )
207
+
208
+ response = client.post_odm(tx.build())
209
+ print(response)
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Diagnosing Submission Failures
215
+
216
+ `RaveDiagnostics` interprets RWS errors after a failed submission, performs
217
+ read-only lookups against RWS, and returns structured, evidence-based
218
+ reports. It never modifies your transaction or selects a study on your behalf.
219
+
220
+ ```python
221
+ from raveforge import RaveTransaction, RWSError, RaveDiagnostics
222
+ from raveforge.rws_client import RWSClient
223
+
224
+ tx = (
225
+ RaveTransaction("Mediflex_Dev")
226
+ .subject("SUBJ-001", "SITE-001")
227
+ .event("SCREENING")
228
+ .form("DM")
229
+ .item_group("DM_IG")
230
+ .item("AGE", "42")
231
+ )
232
+
233
+ client = RWSClient(
234
+ base_url="https://innovate.mdsol.com",
235
+ username="username",
236
+ password="password",
237
+ )
238
+
239
+ diagnostics = RaveDiagnostics(client)
240
+
241
+ try:
242
+ client.post_odm(tx.build())
243
+ except RWSError as error:
244
+ report = diagnostics.explain_submission_failure(error, transaction=tx)
245
+ print(report)
246
+ ```
247
+
248
+ Sample output for a study OID mismatch:
249
+
250
+ ```
251
+ [ERROR] Study Not Found
252
+
253
+ Requested:
254
+ study_oid: Mediflex_Dev
255
+
256
+ Evidence:
257
+ accessible_study_count: 4
258
+ suggested_matches: [{'value': 'Mediflex_DEV', 'similarity': 0.95}]
259
+
260
+ Recommendation:
261
+ Confirm the intended environment and use the exact StudyOID
262
+ configured in Rave. No payload was changed automatically.
263
+
264
+ Safe to retry automatically: False
265
+ ```
266
+
267
+ ---
268
+
269
+ ## Why use RaveForge?
270
+
271
+ - It keeps hierarchy handling readable and safe.
272
+ - It validates your payload locally before bad ODM hits the network.
273
+ - It fails fast with precise, location-aware error messages.
274
+ - It reduces XML-handling boilerplate in clinical integrations.
275
+ - It works well in automation pipelines that generate transactional ODM.
276
+
277
+ ---
278
+
279
+ ## Testing
280
+
281
+ Run the test suite with:
282
+
283
+ ```bash
284
+ pytest -v
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Contributing
290
+
291
+ Pull requests are welcome. If you add new builder or RWS features, please include or update pytest coverage.
292
+
293
+ ---
294
+
295
+ ## License
296
+
297
+ MIT License
@@ -0,0 +1,267 @@
1
+ # RaveForge
2
+
3
+ An elegant Python builder for Medidata Rave ODM XML.
4
+
5
+ Pushing clinical data, lab results, operational signals, or AI-derived outputs into Medidata Rave Web Services (RWS) often becomes a mess of fragile XML string concatenation, namespace issues, and hard-to-maintain hierarchy handling. RaveForge was built to make that process clean, explicit, and reliable.
6
+
7
+ RaveForge is a fluent, stateful Python engine for building RWS-friendly ODM XML without manually writing XML tags. It helps you construct properly nested `Subject > Event > Form > ItemGroup > Item` structures, optionally validates the transaction before serialisation, and optionally submits through a lightweight RWS client.
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ - Fluent builder API for CDISC ODM clinical data transactions
14
+ - Stateful hierarchy handling for `Subject > Event > Form > ItemGroup > Item`
15
+ - Early hierarchy validation with clear Python exceptions
16
+ - **Pre-build structural validation** with aggregated, location-aware error reporting
17
+ - Support for Medidata-specific `mdsol` extensions
18
+ - Query generation with configurable status and recipient
19
+ - Support for item `SpecifyValue`
20
+ - Pretty-printed XML output for debugging
21
+ - Thin RWS client for submission to Medidata RWS
22
+ - Read-only diagnostics layer for interpreting RWS submission failures
23
+ - Tested with `pytest`
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install raveforge
31
+ ```
32
+
33
+ For development:
34
+
35
+ ```bash
36
+ pip install -e .[dev]
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ from raveforge import RaveTransaction, ActionType
45
+
46
+ tx = (
47
+ RaveTransaction(study_oid="Oncology_Phase_II_Prod")
48
+ .subject("SUBJ-001", site_oid="SITE-101", action=ActionType.UPSERT)
49
+ .event("SCREENING", repeat_key="1")
50
+ .form("VS", repeat_key="1")
51
+ .item_group("VS_GROUP", repeat_key="1")
52
+ .item("VSTEST", "Weight")
53
+ .item("VSORRES", "75")
54
+ )
55
+
56
+ xml_payload = tx.build()
57
+ print(tx.build_pretty())
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Pre-build Validation
63
+
64
+ Call `validate()` before `build()` to catch structural and semantic problems early, with a single aggregated report instead of one exception at a time.
65
+
66
+ ```python
67
+ from raveforge import RaveTransaction, validate, ValidationError
68
+
69
+ tx = (
70
+ RaveTransaction(study_oid="Oncology_Phase_II_Prod")
71
+ .subject("SUBJ-001", site_oid="SITE-101")
72
+ .event("SCREENING")
73
+ .form("VS")
74
+ .item_group("VS_GROUP")
75
+ .item("VSORRES", "75")
76
+ )
77
+
78
+ try:
79
+ validate(tx) # raises ValidationError if anything is wrong
80
+ xml_payload = tx.build()
81
+ except ValidationError as err:
82
+ print(err)
83
+ ```
84
+
85
+ Sample output for an empty study OID and a subject with no events:
86
+
87
+ ```
88
+ 2 validation issue(s) found:
89
+ [ERROR] STUDY_OID_EMPTY []: study_oid must not be empty.
90
+ [WARNING] SUBJECT_NO_EVENTS [SUBJ-001]: Subject 'SUBJ-001' has no events. ...
91
+ ```
92
+
93
+ ### Strict vs. non-strict mode
94
+
95
+ By default `validate()` runs in **strict mode**: both `ERROR` and `WARNING`
96
+ severity issues raise `ValidationError`. Pass `strict=False` to raise only on
97
+ errors and receive warnings as return values:
98
+
99
+ ```python
100
+ from raveforge import validate, Severity
101
+
102
+ issues = validate(tx, strict=False) # never raises on WARNING
103
+ warnings = [i for i in issues if i.severity == Severity.WARNING]
104
+ for w in warnings:
105
+ print(w)
106
+ ```
107
+
108
+ ### What the validator checks
109
+
110
+ | Code | Severity | Trigger |
111
+ |---|---|---|
112
+ | `STUDY_OID_EMPTY` | ERROR | `study_oid` is blank or whitespace-only |
113
+ | `STUDY_OID_INVALID_CHARS` | ERROR | `study_oid` contains `<`, `>`, or `&` |
114
+ | `NO_SUBJECTS` | WARNING | Transaction has no subjects at all |
115
+ | `SUBJECT_NO_EVENTS` | WARNING | A subject has no events |
116
+ | `SITE_OID_EMPTY` | ERROR | A subject's `SiteOID` is blank |
117
+ | `EVENT_NO_FORMS` | WARNING | An event has no forms |
118
+ | `EVENT_OID_EMPTY` | ERROR | An event has a blank OID |
119
+ | `FORM_NO_ITEM_GROUPS` | WARNING | A form has no item groups |
120
+ | `FORM_OID_EMPTY` | ERROR | A form has a blank OID |
121
+ | `ITEM_GROUP_OID_EMPTY` | ERROR | An item group has a blank OID |
122
+ | `ITEM_NO_VALUE` | WARNING | An item has no value, specify, or query |
123
+ | `ITEM_OID_EMPTY` | ERROR | An item has a blank OID |
124
+
125
+ ---
126
+
127
+ ## Queries and Specify Values
128
+
129
+ ```python
130
+ from raveforge import (
131
+ RaveTransaction,
132
+ QueryStatus,
133
+ QueryRecipient,
134
+ )
135
+
136
+ tx = (
137
+ RaveTransaction("Mediflex_Study")
138
+ .subject("SUBJ-1001", "SITE-NL-01")
139
+ .event("VISIT_1")
140
+ .form("LABS")
141
+ .item_group("LABS_IG", specified_items_only=True)
142
+ .item(
143
+ "LBTEST",
144
+ value="OTHER",
145
+ specify="Custom Biomarker Panel",
146
+ query="Please confirm the local lab method.",
147
+ query_status=QueryStatus.OPEN,
148
+ query_recipient=QueryRecipient.SITE_FROM_DM,
149
+ )
150
+ )
151
+
152
+ xml_payload = tx.build()
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Submitting to RWS
158
+
159
+ ```python
160
+ from raveforge import RaveTransaction
161
+ from raveforge.rws_client import RWSClient
162
+
163
+ tx = (
164
+ RaveTransaction("Mediflex_Study")
165
+ .subject("SUBJ-001", "SITE-001")
166
+ .event("SCREENING")
167
+ .form("DM")
168
+ .item_group("DM_IG")
169
+ .item("AGE", "42")
170
+ )
171
+
172
+ client = RWSClient(
173
+ base_url="https://innovate.mdsol.com",
174
+ username="username",
175
+ password="password",
176
+ )
177
+
178
+ response = client.post_odm(tx.build())
179
+ print(response)
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Diagnosing Submission Failures
185
+
186
+ `RaveDiagnostics` interprets RWS errors after a failed submission, performs
187
+ read-only lookups against RWS, and returns structured, evidence-based
188
+ reports. It never modifies your transaction or selects a study on your behalf.
189
+
190
+ ```python
191
+ from raveforge import RaveTransaction, RWSError, RaveDiagnostics
192
+ from raveforge.rws_client import RWSClient
193
+
194
+ tx = (
195
+ RaveTransaction("Mediflex_Dev")
196
+ .subject("SUBJ-001", "SITE-001")
197
+ .event("SCREENING")
198
+ .form("DM")
199
+ .item_group("DM_IG")
200
+ .item("AGE", "42")
201
+ )
202
+
203
+ client = RWSClient(
204
+ base_url="https://innovate.mdsol.com",
205
+ username="username",
206
+ password="password",
207
+ )
208
+
209
+ diagnostics = RaveDiagnostics(client)
210
+
211
+ try:
212
+ client.post_odm(tx.build())
213
+ except RWSError as error:
214
+ report = diagnostics.explain_submission_failure(error, transaction=tx)
215
+ print(report)
216
+ ```
217
+
218
+ Sample output for a study OID mismatch:
219
+
220
+ ```
221
+ [ERROR] Study Not Found
222
+
223
+ Requested:
224
+ study_oid: Mediflex_Dev
225
+
226
+ Evidence:
227
+ accessible_study_count: 4
228
+ suggested_matches: [{'value': 'Mediflex_DEV', 'similarity': 0.95}]
229
+
230
+ Recommendation:
231
+ Confirm the intended environment and use the exact StudyOID
232
+ configured in Rave. No payload was changed automatically.
233
+
234
+ Safe to retry automatically: False
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Why use RaveForge?
240
+
241
+ - It keeps hierarchy handling readable and safe.
242
+ - It validates your payload locally before bad ODM hits the network.
243
+ - It fails fast with precise, location-aware error messages.
244
+ - It reduces XML-handling boilerplate in clinical integrations.
245
+ - It works well in automation pipelines that generate transactional ODM.
246
+
247
+ ---
248
+
249
+ ## Testing
250
+
251
+ Run the test suite with:
252
+
253
+ ```bash
254
+ pytest -v
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Contributing
260
+
261
+ Pull requests are welcome. If you add new builder or RWS features, please include or update pytest coverage.
262
+
263
+ ---
264
+
265
+ ## License
266
+
267
+ MIT License
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "raveforge"
7
+ version = "0.4.0"
8
+ description = "An elegant Python builder for Medidata Rave ODM XML."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ authors = [
12
+ { name = "Mohamed Nouira" }
13
+ ]
14
+ license = { text = "MIT" }
15
+ keywords = [
16
+ "medidata",
17
+ "rave",
18
+ "odm",
19
+ "cdisc",
20
+ "clinical-trials",
21
+ "rws",
22
+ "xml"
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: Science/Research",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ "Programming Language :: Python :: 3.8",
33
+ "Programming Language :: Python :: 3.9",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Topic :: Scientific/Engineering :: Medical Science Apps."
38
+ ]
39
+ dependencies = [
40
+ "requests>=2.28"
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ dev = [
45
+ "pytest>=7.0",
46
+ "pytest-cov>=4.0",
47
+ "ruff>=0.4"
48
+ ]
49
+
50
+ [project.urls]
51
+ Repository = "https://github.com/mnouira02/raveforge"
52
+ Issues = "https://github.com/mnouira02/raveforge/issues"
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ pythonpath = ["src"]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py38"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "W", "I"]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-first-party = ["raveforge"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ from .core import RaveTransaction
2
+ from .diagnostics import DiagnosticReport, RaveDiagnostics
3
+ from .enums import ActionType, QueryRecipient, QueryStatus
4
+ from .exceptions import HierarchyError, RaveForgeError, RWSError, ValidationError
5
+ from .rws_client import RWSClient
6
+ from .validator import Severity, ValidationIssue, validate
7
+
8
+ __all__ = [
9
+ # Core builder
10
+ "RaveTransaction",
11
+ # Enums
12
+ "ActionType",
13
+ "QueryStatus",
14
+ "QueryRecipient",
15
+ # Exceptions
16
+ "RaveForgeError",
17
+ "HierarchyError",
18
+ "ValidationError",
19
+ "RWSError",
20
+ # HTTP client
21
+ "RWSClient",
22
+ # Diagnostics
23
+ "RaveDiagnostics",
24
+ "DiagnosticReport",
25
+ # Validation
26
+ "validate",
27
+ "ValidationIssue",
28
+ "Severity",
29
+ ]
30
+
31
+ __version__ = "0.4.0"