Skip to content

Commit a13798f

Browse files
authored
Merge pull request beda-software#22 from Tiro-health/axelv/issue-12-pyo3-analysis-api
Expose analysis API via PyO3
2 parents e842f23 + ca9cba5 commit a13798f

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

fhirpathrs/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from fhirpathrs.engine import do_eval
44
from fhirpathrs.engine.util import arraify, get_data, set_paths, process_user_invocation_table
55
from fhirpathrs.engine.nodes import FP_Type, ResourceNode
6+
from fhirpathrs._rust import (
7+
annotate_expression,
8+
analyze_expression,
9+
QuestionnaireIndex,
10+
)
611

712
__title__ = "fhirpathrs"
813
__version__ = "2.1.0"

src/bindings/python.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
use pyo3::prelude::*;
44
use pyo3::types::{PyDict, PyList};
55

6+
use crate::analyze::{
7+
self, AnnotationKind, DiagnosticCode, Severity, ValueAccessor,
8+
};
69
use crate::lexer::Token;
710
use crate::AstNode;
811

@@ -77,10 +80,166 @@ fn compute_text(node: &AstNode, tokens: &[Token]) -> String {
7780
s
7881
}
7982

83+
// ── QuestionnaireIndex wrapper ──────────────────────────────────────────
84+
85+
#[pyclass(name = "QuestionnaireIndex")]
86+
struct PyQuestionnaireIndex {
87+
inner: analyze::QuestionnaireIndex,
88+
}
89+
90+
#[pymethods]
91+
impl PyQuestionnaireIndex {
92+
#[new]
93+
fn new(questionnaire_json: &str) -> PyResult<Self> {
94+
let value: serde_json::Value = serde_json::from_str(questionnaire_json)
95+
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
96+
Ok(Self {
97+
inner: analyze::QuestionnaireIndex::build(&value),
98+
})
99+
}
100+
101+
fn resolve_item_text(&self, link_id: &str) -> Option<String> {
102+
self.inner.resolve_item_text(link_id).map(|s| s.to_string())
103+
}
104+
105+
fn resolve_code_display(&self, link_id: &str, system: &str, code: &str) -> Option<String> {
106+
self.inner
107+
.resolve_code_display(link_id, system, code)
108+
.map(|s| s.to_string())
109+
}
110+
111+
fn contains(&self, link_id: &str) -> bool {
112+
self.inner.contains(link_id)
113+
}
114+
}
115+
116+
// ── Annotation / analysis helpers ──────────────────────────────────────
117+
118+
fn accessor_to_str(accessor: &ValueAccessor) -> &'static str {
119+
match accessor {
120+
ValueAccessor::Value => "value",
121+
ValueAccessor::Code => "code",
122+
ValueAccessor::Display => "display",
123+
}
124+
}
125+
126+
fn severity_to_str(severity: &Severity) -> &'static str {
127+
match severity {
128+
Severity::Error => "error",
129+
Severity::Warning => "warning",
130+
}
131+
}
132+
133+
fn diagnostic_code_to_str(code: &DiagnosticCode) -> &'static str {
134+
match code {
135+
DiagnosticCode::UnknownLinkId => "unknown_link_id",
136+
DiagnosticCode::UnreachableLinkId => "unreachable_link_id",
137+
DiagnosticCode::InvalidAccessorForType => "invalid_accessor_for_type",
138+
DiagnosticCode::MissingAccessorForCoding => "missing_accessor_for_coding",
139+
DiagnosticCode::ItemReferenceTargetsLeaf => "item_reference_targets_leaf",
140+
DiagnosticCode::ContextUnreachableFromParent => "context_unreachable_from_parent",
141+
}
142+
}
143+
144+
fn annotation_to_pydict(py: Python<'_>, ann: &analyze::Annotation) -> PyResult<PyObject> {
145+
let dict = PyDict::new(py);
146+
dict.set_item("start", ann.span.start)?;
147+
dict.set_item("end", ann.span.end)?;
148+
match &ann.kind {
149+
AnnotationKind::AnswerReference { link_ids, accessor } => {
150+
dict.set_item("kind", "answer_reference")?;
151+
let ids = PyList::new(py, link_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
152+
dict.set_item("link_ids", ids)?;
153+
dict.set_item("accessor", accessor_to_str(accessor))?;
154+
}
155+
AnnotationKind::ItemReference { link_ids } => {
156+
dict.set_item("kind", "item_reference")?;
157+
let ids = PyList::new(py, link_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
158+
dict.set_item("link_ids", ids)?;
159+
}
160+
AnnotationKind::CodedValue {
161+
code,
162+
system,
163+
context_link_id,
164+
} => {
165+
dict.set_item("kind", "coded_value")?;
166+
dict.set_item("code", code.as_str())?;
167+
match system {
168+
Some(s) => dict.set_item("system", s.as_str())?,
169+
None => dict.set_item("system", py.None())?,
170+
}
171+
dict.set_item("context_link_id", context_link_id.as_str())?;
172+
}
173+
}
174+
Ok(dict.into())
175+
}
176+
177+
fn diagnostic_to_pydict(py: Python<'_>, diag: &analyze::Diagnostic) -> PyResult<PyObject> {
178+
let dict = PyDict::new(py);
179+
dict.set_item("severity", severity_to_str(&diag.severity))?;
180+
dict.set_item("code", diagnostic_code_to_str(&diag.code))?;
181+
dict.set_item("message", diag.message.as_str())?;
182+
dict.set_item("start", diag.span.start)?;
183+
dict.set_item("end", diag.span.end)?;
184+
Ok(dict.into())
185+
}
186+
187+
// ── Python-facing functions ────────────────────────────────────────────
188+
189+
/// Annotate a FHIRPath expression, returning a list of annotation dicts.
190+
#[pyfunction]
191+
fn annotate_expression(py: Python<'_>, expr: &str) -> PyResult<PyObject> {
192+
let annotations = analyze::annotate_expression(expr)
193+
.map_err(|e| pyo3::exceptions::PySyntaxError::new_err(e.0))?;
194+
let result: Vec<PyObject> = annotations
195+
.iter()
196+
.map(|a| annotation_to_pydict(py, a))
197+
.collect::<PyResult<Vec<_>>>()?;
198+
Ok(PyList::new(py, result)?.into())
199+
}
200+
201+
/// Analyze a FHIRPath expression against a QuestionnaireIndex, returning
202+
/// annotations and diagnostics.
203+
#[pyfunction]
204+
#[pyo3(signature = (expr, index, context_link_id=None, parent_context_expr=None))]
205+
fn analyze_expression(
206+
py: Python<'_>,
207+
expr: &str,
208+
index: &PyQuestionnaireIndex,
209+
context_link_id: Option<&str>,
210+
parent_context_expr: Option<&str>,
211+
) -> PyResult<PyObject> {
212+
let context = analyze::AnalysisContext {
213+
scope_link_id: context_link_id.map(|s| s.to_string()),
214+
parent_context_expr: parent_context_expr.map(|s| s.to_string()),
215+
};
216+
let result = analyze::analyze_expression(expr, &index.inner, &context)
217+
.map_err(|e| pyo3::exceptions::PySyntaxError::new_err(e.0))?;
218+
219+
let annotations: Vec<PyObject> = result
220+
.annotations
221+
.iter()
222+
.map(|a| annotation_to_pydict(py, a))
223+
.collect::<PyResult<Vec<_>>>()?;
224+
let diagnostics: Vec<PyObject> = result
225+
.diagnostics
226+
.iter()
227+
.map(|d| diagnostic_to_pydict(py, d))
228+
.collect::<PyResult<Vec<_>>>()?;
229+
230+
let dict = PyDict::new(py);
231+
dict.set_item("annotations", PyList::new(py, annotations)?)?;
232+
dict.set_item("diagnostics", PyList::new(py, diagnostics)?)?;
233+
Ok(dict.into())
234+
}
235+
80236
#[pymodule]
81237
pub fn _rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
82238
m.add("IMPLEMENTED", true)?;
83239
m.add_function(wrap_pyfunction!(parse, m)?)?;
240+
m.add_class::<PyQuestionnaireIndex>()?;
241+
m.add_function(wrap_pyfunction!(annotate_expression, m)?)?;
242+
m.add_function(wrap_pyfunction!(analyze_expression, m)?)?;
84243
Ok(())
85244
}
86245

0 commit comments

Comments
 (0)