raveforge 0.4.0__py3-none-any.whl
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.
- raveforge/__init__.py +31 -0
- raveforge/core.py +346 -0
- raveforge/diagnostics.py +470 -0
- raveforge/enums.py +24 -0
- raveforge/exceptions.py +49 -0
- raveforge/rws_client.py +146 -0
- raveforge/validator.py +269 -0
- raveforge-0.4.0.dist-info/METADATA +297 -0
- raveforge-0.4.0.dist-info/RECORD +11 -0
- raveforge-0.4.0.dist-info/WHEEL +5 -0
- raveforge-0.4.0.dist-info/top_level.txt +1 -0
raveforge/__init__.py
ADDED
|
@@ -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"
|
raveforge/core.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
from xml.dom import minidom
|
|
8
|
+
|
|
9
|
+
from .enums import ActionType, QueryRecipient, QueryStatus
|
|
10
|
+
from .exceptions import HierarchyError
|
|
11
|
+
|
|
12
|
+
MDSOL_NS = "http://www.mdsol.com/ns/odm/metadata"
|
|
13
|
+
ODM_NS = "http://www.cdisc.org/ns/odm/v1.3"
|
|
14
|
+
|
|
15
|
+
_DEFAULT_REPEAT_KEY = "1"
|
|
16
|
+
|
|
17
|
+
# Register namespaces once at module load so ET.tostring() emits clean
|
|
18
|
+
# prefixes (xmlns="..." and xmlns:mdsol="...") rather than ns0/ns1.
|
|
19
|
+
ET.register_namespace("", ODM_NS)
|
|
20
|
+
ET.register_namespace("mdsol", MDSOL_NS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RaveTransaction:
|
|
24
|
+
"""
|
|
25
|
+
Builds a CDISC ODM transactional payload for submission to Medidata Rave
|
|
26
|
+
Web Services (RWS), including Medidata-specific ODM extensions.
|
|
27
|
+
|
|
28
|
+
Supports a fluent/chained builder API::
|
|
29
|
+
|
|
30
|
+
tx = RaveTransaction("MY_STUDY")
|
|
31
|
+
xml_bytes = (
|
|
32
|
+
tx.subject("SUBJ-001", "SITE-01", ActionType.UPDATE)
|
|
33
|
+
.event("VISIT_1", repeat_key="1")
|
|
34
|
+
.form("DEMOGRAPHICS")
|
|
35
|
+
.item_group("DM_IG", repeat_key="1", specified_items_only=True)
|
|
36
|
+
.item("AGE", value="34")
|
|
37
|
+
.build()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Pre-build validation::
|
|
41
|
+
|
|
42
|
+
from raveforge import validate
|
|
43
|
+
validate(tx) # raises ValidationError if the transaction is malformed
|
|
44
|
+
xml_bytes = tx.build() # safe to call after validation passes
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, study_oid: str, metadata_version_oid: str = "1") -> None:
|
|
48
|
+
self.study_oid: str = study_oid
|
|
49
|
+
self.metadata_version_oid: str = metadata_version_oid
|
|
50
|
+
self.file_oid: str = str(uuid.uuid4())
|
|
51
|
+
|
|
52
|
+
self._subjects: Dict[str, Dict[str, Any]] = {}
|
|
53
|
+
self._current_subject: Optional[str] = None
|
|
54
|
+
self._current_site: Optional[str] = None
|
|
55
|
+
self._current_event: Optional[str] = None
|
|
56
|
+
self._current_form: Optional[str] = None
|
|
57
|
+
self._current_group: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Context manager support
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> RaveTransaction:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Optional[bool]:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Builder methods
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def subject(
|
|
74
|
+
self,
|
|
75
|
+
subject_key: str,
|
|
76
|
+
site_oid: str,
|
|
77
|
+
action: Optional[ActionType] = None,
|
|
78
|
+
) -> RaveTransaction:
|
|
79
|
+
"""Add or revisit a subject context.
|
|
80
|
+
|
|
81
|
+
If the subject already exists in this transaction, its SiteOID and
|
|
82
|
+
Action are updated to the values provided in the current call.
|
|
83
|
+
The subject's accumulated events are always preserved.
|
|
84
|
+
"""
|
|
85
|
+
if subject_key not in self._subjects:
|
|
86
|
+
self._subjects[subject_key] = {"Events": {}}
|
|
87
|
+
|
|
88
|
+
self._subjects[subject_key]["SiteOID"] = site_oid
|
|
89
|
+
self._subjects[subject_key]["Action"] = action.value if action else None
|
|
90
|
+
|
|
91
|
+
self._current_subject = subject_key
|
|
92
|
+
self._current_site = site_oid
|
|
93
|
+
self._current_event = None
|
|
94
|
+
self._current_form = None
|
|
95
|
+
self._current_group = None
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def event(
|
|
99
|
+
self,
|
|
100
|
+
event_oid: str,
|
|
101
|
+
repeat_key: Optional[str] = None,
|
|
102
|
+
action: Optional[ActionType] = None,
|
|
103
|
+
) -> RaveTransaction:
|
|
104
|
+
"""Add or switch to a study event context."""
|
|
105
|
+
if not self._current_subject:
|
|
106
|
+
raise HierarchyError("Subject context required before calling event().")
|
|
107
|
+
effective_repeat_key = (
|
|
108
|
+
repeat_key if repeat_key is not None else _DEFAULT_REPEAT_KEY
|
|
109
|
+
)
|
|
110
|
+
events = self._subjects[self._current_subject]["Events"]
|
|
111
|
+
event_key = f"{event_oid}_{effective_repeat_key}"
|
|
112
|
+
if event_key not in events:
|
|
113
|
+
events[event_key] = {
|
|
114
|
+
"OID": event_oid,
|
|
115
|
+
"RepeatKey": effective_repeat_key,
|
|
116
|
+
"Action": action.value if action else None,
|
|
117
|
+
"Forms": {},
|
|
118
|
+
}
|
|
119
|
+
self._current_event = event_key
|
|
120
|
+
self._current_form = None
|
|
121
|
+
self._current_group = None
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def form(
|
|
125
|
+
self,
|
|
126
|
+
form_oid: str,
|
|
127
|
+
repeat_key: Optional[str] = None,
|
|
128
|
+
action: Optional[ActionType] = None,
|
|
129
|
+
) -> RaveTransaction:
|
|
130
|
+
"""Add or switch to a form context."""
|
|
131
|
+
if not self._current_event:
|
|
132
|
+
raise HierarchyError("Event context required before calling form().")
|
|
133
|
+
effective_repeat_key = (
|
|
134
|
+
repeat_key if repeat_key is not None else _DEFAULT_REPEAT_KEY
|
|
135
|
+
)
|
|
136
|
+
forms = (
|
|
137
|
+
self._subjects[self._current_subject]
|
|
138
|
+
["Events"][self._current_event]["Forms"]
|
|
139
|
+
)
|
|
140
|
+
form_key = f"{form_oid}_{effective_repeat_key}"
|
|
141
|
+
if form_key not in forms:
|
|
142
|
+
forms[form_key] = {
|
|
143
|
+
"OID": form_oid,
|
|
144
|
+
"RepeatKey": effective_repeat_key,
|
|
145
|
+
"Action": action.value if action else None,
|
|
146
|
+
"ItemGroups": {},
|
|
147
|
+
}
|
|
148
|
+
self._current_form = form_key
|
|
149
|
+
self._current_group = None
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def item_group(
|
|
153
|
+
self,
|
|
154
|
+
item_group_oid: str,
|
|
155
|
+
repeat_key: Optional[str] = None,
|
|
156
|
+
action: Optional[ActionType] = None,
|
|
157
|
+
specified_items_only: bool = False,
|
|
158
|
+
) -> RaveTransaction:
|
|
159
|
+
"""Add or switch to an item group context."""
|
|
160
|
+
if not self._current_form:
|
|
161
|
+
raise HierarchyError("Form context required before calling item_group().")
|
|
162
|
+
effective_repeat_key = (
|
|
163
|
+
repeat_key if repeat_key is not None else _DEFAULT_REPEAT_KEY
|
|
164
|
+
)
|
|
165
|
+
groups = (
|
|
166
|
+
self._subjects[self._current_subject]
|
|
167
|
+
["Events"][self._current_event]
|
|
168
|
+
["Forms"][self._current_form]["ItemGroups"]
|
|
169
|
+
)
|
|
170
|
+
group_key = f"{item_group_oid}_{effective_repeat_key}"
|
|
171
|
+
if group_key not in groups:
|
|
172
|
+
groups[group_key] = {
|
|
173
|
+
"OID": item_group_oid,
|
|
174
|
+
"RepeatKey": effective_repeat_key,
|
|
175
|
+
"Action": action.value if action else None,
|
|
176
|
+
"SpecifiedItemsOnly": specified_items_only,
|
|
177
|
+
"Items": {},
|
|
178
|
+
}
|
|
179
|
+
self._current_group = group_key
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def item(
|
|
183
|
+
self,
|
|
184
|
+
item_oid: str,
|
|
185
|
+
value: Optional[str] = None,
|
|
186
|
+
specify: Optional[str] = None,
|
|
187
|
+
query: Optional[str] = None,
|
|
188
|
+
query_status: QueryStatus = QueryStatus.OPEN,
|
|
189
|
+
query_recipient: QueryRecipient = QueryRecipient.SITE_FROM_SYSTEM,
|
|
190
|
+
) -> RaveTransaction:
|
|
191
|
+
"""
|
|
192
|
+
Add an item (field value) to the current item group.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
item_oid: The ODM ItemOID.
|
|
196
|
+
value: The data value to submit.
|
|
197
|
+
specify: Free-text value for coded items with an open-other response.
|
|
198
|
+
query: Query text to attach as an mdsol:Query element.
|
|
199
|
+
query_status: Status of the query (default: Open).
|
|
200
|
+
query_recipient: Recipient of the query (default: Site from System).
|
|
201
|
+
"""
|
|
202
|
+
if not self._current_group:
|
|
203
|
+
raise HierarchyError("ItemGroup context required before calling item().")
|
|
204
|
+
items = (
|
|
205
|
+
self._subjects[self._current_subject]
|
|
206
|
+
["Events"][self._current_event]
|
|
207
|
+
["Forms"][self._current_form]
|
|
208
|
+
["ItemGroups"][self._current_group]["Items"]
|
|
209
|
+
)
|
|
210
|
+
items[item_oid] = {
|
|
211
|
+
"Value": value,
|
|
212
|
+
"Specify": specify,
|
|
213
|
+
"Query": query,
|
|
214
|
+
"QueryStatus": query_status.value,
|
|
215
|
+
"QueryRecipient": query_recipient.value,
|
|
216
|
+
}
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Reset helpers
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def reset_context(self) -> RaveTransaction:
|
|
224
|
+
"""Clear all active context pointers without discarding accumulated data."""
|
|
225
|
+
self._current_subject = None
|
|
226
|
+
self._current_site = None
|
|
227
|
+
self._current_event = None
|
|
228
|
+
self._current_form = None
|
|
229
|
+
self._current_group = None
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
def reset(self) -> RaveTransaction:
|
|
233
|
+
"""Fully reset the transaction, clearing all subjects and regenerating file identity."""
|
|
234
|
+
self._subjects = {}
|
|
235
|
+
self.file_oid = str(uuid.uuid4())
|
|
236
|
+
return self.reset_context()
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Build
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def build(self, encoding: str = "UTF-8") -> bytes:
|
|
243
|
+
"""Serialise the transaction to ODM XML bytes."""
|
|
244
|
+
root = ET.Element("ODM", {
|
|
245
|
+
"xmlns": ODM_NS,
|
|
246
|
+
"FileType": "Transactional",
|
|
247
|
+
"FileOID": self.file_oid,
|
|
248
|
+
"CreationDateTime": (
|
|
249
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
250
|
+
),
|
|
251
|
+
"ODMVersion": "1.3",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
clinical_data = ET.SubElement(root, "ClinicalData", {
|
|
255
|
+
"StudyOID": self.study_oid,
|
|
256
|
+
"MetaDataVersionOID": self.metadata_version_oid,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
for subj_key, subj_data in self._subjects.items():
|
|
260
|
+
subj_attribs: Dict[str, str] = {"SubjectKey": subj_key}
|
|
261
|
+
if subj_data["Action"]:
|
|
262
|
+
subj_attribs["TransactionType"] = subj_data["Action"]
|
|
263
|
+
|
|
264
|
+
subj_node = ET.SubElement(clinical_data, "SubjectData", subj_attribs)
|
|
265
|
+
ET.SubElement(subj_node, "SiteRef", {"LocationOID": subj_data["SiteOID"]})
|
|
266
|
+
|
|
267
|
+
for event_data in subj_data["Events"].values():
|
|
268
|
+
event_attribs: Dict[str, str] = {
|
|
269
|
+
"StudyEventOID": event_data["OID"],
|
|
270
|
+
"StudyEventRepeatKey": event_data["RepeatKey"],
|
|
271
|
+
}
|
|
272
|
+
if event_data["Action"]:
|
|
273
|
+
event_attribs["TransactionType"] = event_data["Action"]
|
|
274
|
+
event_node = ET.SubElement(
|
|
275
|
+
subj_node, "StudyEventData", event_attribs
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
for form_data in event_data["Forms"].values():
|
|
279
|
+
form_attribs: Dict[str, str] = {
|
|
280
|
+
"FormOID": form_data["OID"],
|
|
281
|
+
"FormRepeatKey": form_data["RepeatKey"],
|
|
282
|
+
}
|
|
283
|
+
if form_data["Action"]:
|
|
284
|
+
form_attribs["TransactionType"] = form_data["Action"]
|
|
285
|
+
form_node = ET.SubElement(
|
|
286
|
+
event_node, "FormData", form_attribs
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
for group_data in form_data["ItemGroups"].values():
|
|
290
|
+
group_attribs: Dict[str, str] = {
|
|
291
|
+
"ItemGroupOID": group_data["OID"]
|
|
292
|
+
}
|
|
293
|
+
if group_data["RepeatKey"] is not None:
|
|
294
|
+
group_attribs["ItemGroupRepeatKey"] = (
|
|
295
|
+
group_data["RepeatKey"]
|
|
296
|
+
)
|
|
297
|
+
if group_data["Action"]:
|
|
298
|
+
group_attribs["TransactionType"] = group_data["Action"]
|
|
299
|
+
if group_data["SpecifiedItemsOnly"]:
|
|
300
|
+
group_attribs[f"{{{MDSOL_NS}}}Submission"] = (
|
|
301
|
+
"SpecifiedItemsOnly"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
group_node = ET.SubElement(
|
|
305
|
+
form_node, "ItemGroupData", group_attribs
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
for item_oid, item_dict in group_data["Items"].items():
|
|
309
|
+
item_attribs: Dict[str, str] = {"ItemOID": item_oid}
|
|
310
|
+
if item_dict["Value"] is not None:
|
|
311
|
+
item_attribs["Value"] = str(item_dict["Value"])
|
|
312
|
+
if item_dict["Specify"] is not None:
|
|
313
|
+
specify_key = f"{{{MDSOL_NS}}}SpecifyValue"
|
|
314
|
+
item_attribs[specify_key] = str(
|
|
315
|
+
item_dict["Specify"]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
item_node = ET.SubElement(
|
|
319
|
+
group_node, "ItemData", item_attribs
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if item_dict["Query"]:
|
|
323
|
+
ET.SubElement(
|
|
324
|
+
item_node,
|
|
325
|
+
f"{{{MDSOL_NS}}}Query",
|
|
326
|
+
{
|
|
327
|
+
"Value": str(item_dict["Query"]),
|
|
328
|
+
"Status": item_dict["QueryStatus"],
|
|
329
|
+
"Recipient": item_dict["QueryRecipient"],
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return ET.tostring(root, encoding=encoding, xml_declaration=True)
|
|
334
|
+
|
|
335
|
+
def build_pretty(self) -> str:
|
|
336
|
+
"""
|
|
337
|
+
Serialise to a human-readable, indented XML string.
|
|
338
|
+
|
|
339
|
+
Returns a Unicode string without an encoding declaration.
|
|
340
|
+
Intended for debugging and logging; use :meth:`build` for transmission.
|
|
341
|
+
"""
|
|
342
|
+
# encoding="unicode" produces a bare string with no XML declaration,
|
|
343
|
+
# allowing minidom.toprettyxml to add its own without duplication.
|
|
344
|
+
raw = self.build(encoding="unicode")
|
|
345
|
+
parsed = minidom.parseString(raw)
|
|
346
|
+
return parsed.toprettyxml(indent=" ", encoding=None)
|