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 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)