ontoenv_python/
lib.rs

1use ::ontoenv::api::{OntoEnv as OntoEnvRs, ResolveTarget};
2use ::ontoenv::config;
3use ::ontoenv::consts::{IMPORTS, ONTOLOGY, TYPE};
4use ::ontoenv::ontology::{Ontology as OntologyRs, OntologyLocation};
5use ::ontoenv::options::{CacheMode, Overwrite, RefreshStrategy};
6use ::ontoenv::transform;
7use ::ontoenv::ToUriString;
8use anyhow::Error;
9#[cfg(feature = "cli")]
10use ontoenv_cli;
11use oxigraph::model::{BlankNode, Literal, NamedNode, NamedOrBlankNodeRef, Term};
12use pyo3::{
13    prelude::*,
14    types::{IntoPyDict, PyString, PyTuple},
15    exceptions::PyValueError,
16};
17#[cfg(not(feature = "cli"))]
18use pyo3::exceptions::PyRuntimeError;
19use std::borrow::Borrow;
20use std::collections::{HashMap, HashSet};
21use std::path::PathBuf;
22use std::ffi::OsStr;
23use std::sync::{Arc, Mutex};
24
25fn anyhow_to_pyerr(e: Error) -> PyErr {
26    PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string())
27}
28
29// Helper function to format paths with forward slashes for cross-platform error messages
30fn format_path_for_error(path: &std::path::Path) -> String {
31    path.to_string_lossy().replace('\\', "/")
32}
33
34#[allow(dead_code)]
35struct MyTerm(Term);
36impl From<Result<Bound<'_, PyAny>, pyo3::PyErr>> for MyTerm {
37    fn from(s: Result<Bound<'_, PyAny>, pyo3::PyErr>) -> Self {
38        let s = s.unwrap();
39        let typestr = s.get_type().name().unwrap();
40        let typestr = typestr.to_string();
41        let data_type: Option<NamedNode> = match s.getattr("datatype") {
42            Ok(dt) => {
43                if dt.is_none() {
44                    None
45                } else {
46                    Some(NamedNode::new(dt.to_string()).unwrap())
47                }
48            }
49            Err(_) => None,
50        };
51        let lang: Option<String> = match s.getattr("language") {
52            Ok(l) => {
53                if l.is_none() {
54                    None
55                } else {
56                    Some(l.to_string())
57                }
58            }
59            Err(_) => None,
60        };
61        let n: Term = match typestr.borrow() {
62            "URIRef" => Term::NamedNode(NamedNode::new(s.to_string()).unwrap()),
63            "Literal" => match (data_type, lang) {
64                (Some(dt), None) => Term::Literal(Literal::new_typed_literal(s.to_string(), dt)),
65                (None, Some(l)) => {
66                    Term::Literal(Literal::new_language_tagged_literal(s.to_string(), l).unwrap())
67                }
68                (_, _) => Term::Literal(Literal::new_simple_literal(s.to_string())),
69            },
70            "BNode" => Term::BlankNode(BlankNode::new(s.to_string()).unwrap()),
71            _ => Term::NamedNode(NamedNode::new(s.to_string()).unwrap()),
72        };
73        MyTerm(n)
74    }
75}
76
77fn term_to_python<'a>(
78    py: Python,
79    rdflib: &Bound<'a, PyModule>,
80    node: Term,
81) -> PyResult<Bound<'a, PyAny>> {
82    let dtype: Option<String> = match &node {
83        Term::Literal(lit) => {
84            let mut s = lit.datatype().to_string();
85            s.remove(0);
86            s.remove(s.len() - 1);
87            Some(s)
88        }
89        _ => None,
90    };
91    let lang: Option<&str> = match &node {
92        Term::Literal(lit) => lit.language(),
93        _ => None,
94    };
95
96    let res: Bound<'_, PyAny> = match &node {
97        Term::NamedNode(uri) => {
98            let mut uri = uri.to_string();
99            uri.remove(0);
100            uri.remove(uri.len() - 1);
101            rdflib.getattr("URIRef")?.call1((uri,))?
102        }
103        Term::Literal(literal) => {
104            match (dtype, lang) {
105                // prioritize 'lang' -> it implies String
106                (_, Some(lang)) => {
107                    rdflib
108                        .getattr("Literal")?
109                        .call1((literal.value(), lang, py.None()))?
110                }
111                (Some(dtype), None) => {
112                    rdflib
113                        .getattr("Literal")?
114                        .call1((literal.value(), py.None(), dtype))?
115                }
116                (None, None) => rdflib.getattr("Literal")?.call1((literal.value(),))?,
117            }
118        }
119        Term::BlankNode(id) => rdflib
120            .getattr("BNode")?
121            .call1((id.clone().into_string(),))?,
122    };
123    Ok(res)
124}
125
126/// Run the Rust CLI implementation and return its process-style exit code.
127#[pyfunction]
128#[cfg(feature = "cli")]
129fn run_cli(py: Python<'_>, args: Option<Vec<String>>) -> PyResult<i32> {
130    let argv = args.unwrap_or_else(|| std::env::args().collect());
131    let code = py.allow_threads(move || match ontoenv_cli::run_from_args(argv) {
132        Ok(()) => 0,
133        Err(err) => {
134            eprintln!("{err}");
135            1
136        }
137    });
138    Ok(code)
139}
140
141/// Fallback stub when the CLI feature is disabled at compile time.
142#[pyfunction]
143#[cfg(not(feature = "cli"))]
144#[allow(unused_variables)]
145fn run_cli(_py: Python<'_>, _args: Option<Vec<String>>) -> PyResult<i32> {
146    Err(PyErr::new::<PyRuntimeError, _>(
147        "ontoenv was built without CLI support; rebuild with the 'cli' feature",
148    ))
149}
150
151#[pyclass(name = "Ontology")]
152#[derive(Clone)]
153struct PyOntology {
154    inner: OntologyRs,
155}
156
157#[pymethods]
158impl PyOntology {
159    #[getter]
160    fn id(&self) -> PyResult<String> {
161        Ok(self.inner.id().to_uri_string())
162    }
163
164    #[getter]
165    fn name(&self) -> PyResult<String> {
166        Ok(self.inner.name().to_uri_string())
167    }
168
169    #[getter]
170    fn imports(&self) -> PyResult<Vec<String>> {
171        Ok(self
172            .inner
173            .imports
174            .iter()
175            .map(|i| i.to_uri_string())
176            .collect())
177    }
178
179    #[getter]
180    fn location(&self) -> PyResult<Option<String>> {
181        Ok(self.inner.location().map(|l| l.to_string()))
182    }
183
184    #[getter]
185    fn last_updated(&self) -> PyResult<Option<String>> {
186        Ok(self.inner.last_updated.map(|dt| dt.to_rfc3339()))
187    }
188
189    #[getter]
190    fn version_properties(&self) -> PyResult<HashMap<String, String>> {
191        Ok(self
192            .inner
193            .version_properties()
194            .iter()
195            .map(|(k, v)| (k.to_uri_string(), v.clone()))
196            .collect())
197    }
198
199    #[getter]
200    fn namespace_map(&self) -> PyResult<HashMap<String, String>> {
201        Ok(self.inner.namespace_map().clone())
202    }
203
204    fn __repr__(&self) -> PyResult<String> {
205        Ok(format!("<Ontology: {}>", self.inner.name().to_uri_string()))
206    }
207}
208
209#[pyclass]
210struct OntoEnv {
211    inner: Arc<Mutex<Option<OntoEnvRs>>>,
212}
213
214#[pymethods]
215impl OntoEnv {
216    #[new]
217    #[pyo3(signature = (path=None, recreate=false, read_only=false, search_directories=None, require_ontology_names=false, strict=false, offline=false, use_cached_ontologies=false, resolution_policy="default".to_owned(), root=".".to_owned(), includes=None, excludes=None, temporary=false, no_search=false))]
218    fn new(
219        _py: Python,
220        path: Option<PathBuf>,
221        recreate: bool,
222        read_only: bool,
223        search_directories: Option<Vec<String>>,
224        require_ontology_names: bool,
225        strict: bool,
226        offline: bool,
227        use_cached_ontologies: bool,
228        resolution_policy: String,
229        root: String,
230        includes: Option<Vec<String>>,
231        excludes: Option<Vec<String>>,
232        temporary: bool,
233        no_search: bool,
234    ) -> PyResult<Self> {
235
236        // Check if OntoEnv() is called without any meaningful arguments
237        // This implements the behavior expected by the tests
238        if path.is_none() && root == "." && !recreate && !temporary {
239            // Use forward slashes for cross-platform compatibility in error messages
240            return Err(PyValueError::new_err(
241                "OntoEnv directory not found at \"./.ontoenv\". You must provide a valid path or set recreate=True or temporary=True to create a new OntoEnv.",
242            ));
243        }
244        let mut root_path = path.clone().unwrap_or_else(|| PathBuf::from(root));
245        // If the provided path points to a '.ontoenv' directory, treat its parent as the root
246        if root_path
247            .file_name()
248            .map(|n| n == OsStr::new(".ontoenv"))
249            .unwrap_or(false)
250        {
251            if let Some(parent) = root_path.parent() {
252                root_path = parent.to_path_buf();
253            }
254        }
255
256        // Strict Git-like behavior:
257        // - temporary=True: create a temporary (in-memory) env
258        // - recreate=True: create (or overwrite) an env at root_path
259        // - otherwise: discover upward; if not found, error
260
261        let mut builder = config::Config::builder()
262            .root(root_path.clone())
263            .require_ontology_names(require_ontology_names)
264            .strict(strict)
265            .offline(offline)
266            .use_cached_ontologies(CacheMode::from(use_cached_ontologies))
267            .resolution_policy(resolution_policy)
268            .temporary(temporary)
269            .no_search(no_search);
270
271        if let Some(dirs) = search_directories {
272            let paths = dirs.into_iter().map(PathBuf::from).collect();
273            builder = builder.locations(paths);
274        }
275        if let Some(incl) = includes {
276            builder = builder.includes(incl);
277        }
278        if let Some(excl) = excludes {
279            builder = builder.excludes(excl);
280        }
281
282        let cfg = builder
283            .build()
284            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
285
286        let env = if cfg.temporary {
287            // Explicit in-memory env
288            OntoEnvRs::init(cfg, false).map_err(anyhow_to_pyerr)?
289        } else if recreate {
290            // Explicit create/overwrite at root_path
291            OntoEnvRs::init(cfg, true).map_err(anyhow_to_pyerr)?
292        } else {
293            // Discover upward from root_path; load if found. If not found and not read-only,
294            // initialize a new environment at the requested root.
295            match ::ontoenv::api::find_ontoenv_root_from(&root_path) {
296                Some(found_root) => OntoEnvRs::load_from_directory(found_root, read_only)
297                    .map_err(anyhow_to_pyerr)?,
298                None => {
299                    // If a specific path was provided but no .ontoenv exists, raise error
300                    if path.is_some() {
301                        return Err(PyValueError::new_err(format!(
302                            "OntoEnv directory not found at: \"{}\"",
303                            format_path_for_error(&root_path.join(".ontoenv"))
304                        )));
305                    }
306                    if read_only {
307                        return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
308                            "OntoEnv directory not found at: \"{}\" and read_only=True",
309                            format_path_for_error(&root_path.join(".ontoenv"))
310                        )));
311                    }
312                    OntoEnvRs::init(cfg, false).map_err(anyhow_to_pyerr)?
313                }
314            }
315        };
316
317        let inner = Arc::new(Mutex::new(Some(env)));
318
319        Ok(OntoEnv {
320            inner: inner.clone(),
321        })
322    }
323
324    #[pyo3(signature = (all=false))]
325    fn update(&self, all: bool) -> PyResult<()> {
326        let inner = self.inner.clone();
327        let mut guard = inner.lock().unwrap();
328        if let Some(env) = guard.as_mut() {
329            env.update_all(all).map_err(anyhow_to_pyerr)?;
330            env.save_to_directory().map_err(anyhow_to_pyerr)
331        } else {
332            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
333                "OntoEnv is closed",
334            ))
335        }
336    }
337
338    // fn is_read_only(&self) -> PyResult<bool> {
339    //     let inner = self.inner.clone();
340    //     let env = inner.lock().unwrap();
341    //     Ok(env.is_read_only())
342    // }
343
344    fn __repr__(&self) -> PyResult<String> {
345        let inner = self.inner.clone();
346        let guard = inner.lock().unwrap();
347        if let Some(env) = guard.as_ref() {
348            let stats = env.stats().map_err(anyhow_to_pyerr)?;
349            Ok(format!(
350                "<OntoEnv: {} ontologies, {} graphs, {} triples>",
351                stats.num_ontologies, stats.num_graphs, stats.num_triples,
352            ))
353        } else {
354            Ok("<OntoEnv: closed>".to_string())
355        }
356    }
357
358    // The following methods will now access the inner OntoEnv in a thread-safe manner:
359
360    fn import_graph(
361        &self,
362        py: Python,
363        destination_graph: &Bound<'_, PyAny>,
364        uri: &str,
365    ) -> PyResult<()> {
366        let inner = self.inner.clone();
367        let mut guard = inner.lock().unwrap();
368        let env = guard
369            .as_mut()
370            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
371        let rdflib = py.import("rdflib")?;
372        let iri = NamedNode::new(uri)
373            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
374        let graphid = env
375            .resolve(ResolveTarget::Graph(iri.clone()))
376            .ok_or_else(|| {
377                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
378                    "Failed to resolve graph for URI: {uri}"
379                ))
380            })?;
381        let mut graph = env.get_graph(&graphid).map_err(anyhow_to_pyerr)?;
382
383        let uriref_constructor = rdflib.getattr("URIRef")?;
384        let type_uri = uriref_constructor.call1((TYPE.as_str(),))?;
385        let ontology_uri = uriref_constructor.call1((ONTOLOGY.as_str(),))?;
386        let kwargs = [("predicate", type_uri), ("object", ontology_uri)].into_py_dict(py)?;
387        let result = destination_graph.call_method("value", (), Some(&kwargs))?;
388        if !result.is_none() {
389            let ontology = NamedNode::new(result.extract::<String>()?)
390                .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
391            let base_ontology = NamedOrBlankNodeRef::NamedNode(ontology.as_ref());
392
393            transform::rewrite_sh_prefixes_graph(&mut graph, base_ontology);
394            transform::remove_ontology_declarations_graph(&mut graph, base_ontology);
395        }
396        // remove the owl:import statement for the 'uri' ontology
397        transform::remove_owl_imports_graph(&mut graph, Some(&[iri.as_ref()]));
398
399        Python::with_gil(|_py| {
400            for triple in graph.into_iter() {
401                let s: Term = triple.subject.into();
402                let p: Term = triple.predicate.into();
403                let o: Term = triple.object.into();
404
405                let t = PyTuple::new(
406                    py,
407                    &[
408                        term_to_python(py, &rdflib, s)?,
409                        term_to_python(py, &rdflib, p)?,
410                        term_to_python(py, &rdflib, o)?,
411                    ],
412                )?;
413
414                destination_graph.getattr("add")?.call1((t,))?;
415            }
416            Ok::<(), PyErr>(())
417        })?;
418        Ok(())
419    }
420
421    /// List the ontologies in the imports closure of the given ontology
422    #[pyo3(signature = (uri, recursion_depth = -1))]
423    fn list_closure(&self, _py: Python, uri: &str, recursion_depth: i32) -> PyResult<Vec<String>> {
424        let iri = NamedNode::new(uri)
425            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
426        let inner = self.inner.clone();
427        let mut guard = inner.lock().unwrap();
428        let env = guard
429            .as_mut()
430            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
431        let graphid = env
432            .resolve(ResolveTarget::Graph(iri.clone()))
433            .ok_or_else(|| {
434                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
435                    "Failed to resolve graph for URI: {uri}"
436                ))
437            })?;
438        let ont = env.ontologies().get(&graphid).ok_or_else(|| {
439            PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Ontology {iri} not found"))
440        })?;
441        let closure = env
442            .get_closure(ont.id(), recursion_depth)
443            .map_err(anyhow_to_pyerr)?;
444        let names: Vec<String> = closure.iter().map(|ont| ont.to_uri_string()).collect();
445        Ok(names)
446    }
447
448    /// Merge the imports closure of `uri` into a single graph and return it alongside the closure list.
449    ///
450    /// The first element of the returned tuple is either the provided `destination_graph` (after
451    /// mutation) or a brand-new `rdflib.Graph`. The second element is an ordered list of ontology
452    /// IRIs in the resolved closure starting with `uri`. Set `rewrite_sh_prefixes` or
453    /// `remove_owl_imports` to control post-processing of the merged triples.
454    #[pyo3(signature = (uri, destination_graph=None, rewrite_sh_prefixes=true, remove_owl_imports=true, recursion_depth=-1))]
455    fn get_closure<'a>(
456        &self,
457        py: Python<'a>,
458        uri: &str,
459        destination_graph: Option<&Bound<'a, PyAny>>,
460        rewrite_sh_prefixes: bool,
461        remove_owl_imports: bool,
462        recursion_depth: i32,
463    ) -> PyResult<(Bound<'a, PyAny>, Vec<String>)> {
464        let rdflib = py.import("rdflib")?;
465        let iri = NamedNode::new(uri)
466            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
467        let inner = self.inner.clone();
468        let mut guard = inner.lock().unwrap();
469        let env = guard
470            .as_mut()
471            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
472        let graphid = env
473            .resolve(ResolveTarget::Graph(iri.clone()))
474            .ok_or_else(|| {
475                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("No graph with URI: {uri}"))
476            })?;
477        let ont = env.ontologies().get(&graphid).ok_or_else(|| {
478            PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Ontology {iri} not found"))
479        })?;
480        let closure = env
481            .get_closure(ont.id(), recursion_depth)
482            .map_err(anyhow_to_pyerr)?;
483        let closure_names: Vec<String> = closure.iter().map(|ont| ont.to_uri_string()).collect();
484        // if destination_graph is null, create a new rdflib.Graph()
485        let destination_graph = match destination_graph {
486            Some(g) => g.clone(),
487            None => rdflib.getattr("Graph")?.call0()?,
488        };
489        let union = env
490            .get_union_graph(
491                &closure,
492                Some(rewrite_sh_prefixes),
493                Some(remove_owl_imports),
494            )
495            .map_err(anyhow_to_pyerr)?;
496        for triple in union.dataset.into_iter() {
497            let s: Term = triple.subject.into();
498            let p: Term = triple.predicate.into();
499            let o: Term = triple.object.into();
500            let t = PyTuple::new(
501                py,
502                &[
503                    term_to_python(py, &rdflib, s)?,
504                    term_to_python(py, &rdflib, p)?,
505                    term_to_python(py, &rdflib, o)?,
506                ],
507            )?;
508            destination_graph.getattr("add")?.call1((t,))?;
509        }
510
511        // Remove each successful_imports url in the closure from the destination_graph
512        if remove_owl_imports {
513            for graphid in union.graph_ids {
514                let iri = term_to_python(py, &rdflib, Term::NamedNode(graphid.into()))?;
515                let pred = term_to_python(py, &rdflib, IMPORTS.into())?;
516                // remove triples with (None, pred, iri)
517                let remove_tuple = PyTuple::new(py, &[py.None(), pred.into(), iri.into()])?;
518                destination_graph
519                    .getattr("remove")?
520                    .call1((remove_tuple,))?;
521            }
522        }
523        Ok((destination_graph, closure_names))
524    }
525
526    /// Print the contents of the OntoEnv
527    #[pyo3(signature = (includes=None))]
528    fn dump(&self, _py: Python, includes: Option<String>) -> PyResult<()> {
529        let inner = self.inner.clone();
530        let guard = inner.lock().unwrap();
531        if let Some(env) = guard.as_ref() {
532            env.dump(includes.as_deref());
533            Ok(())
534        } else {
535            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
536                "OntoEnv is closed",
537            ))
538        }
539    }
540
541    /// Import the dependencies referenced by `owl:imports` triples in `graph`.
542    ///
543    /// When `fetch_missing` is true, the environment attempts to download unresolved imports
544    /// before computing the closure. After merging the closure triples into `graph`, all
545    /// `owl:imports` statements are removed. The returned list contains the deduplicated ontology
546    /// IRIs that were successfully imported.
547    #[pyo3(signature = (graph, recursion_depth=-1, fetch_missing=false))]
548    fn import_dependencies<'a>(
549        &self,
550        py: Python<'a>,
551        graph: &Bound<'a, PyAny>,
552        recursion_depth: i32,
553        fetch_missing: bool,
554    ) -> PyResult<Vec<String>> {
555        let rdflib = py.import("rdflib")?;
556        let py_imports_pred = term_to_python(py, &rdflib, Term::NamedNode(IMPORTS.into()))?;
557
558        let kwargs = [("predicate", py_imports_pred)].into_py_dict(py)?;
559        let objects_iter = graph.call_method("objects", (), Some(&kwargs))?;
560        let builtins = py.import("builtins")?;
561        let objects_list = builtins.getattr("list")?.call1((objects_iter,))?;
562        let imports: Vec<String> = objects_list.extract()?;
563
564        if imports.is_empty() {
565            return Ok(Vec::new());
566        }
567
568        let inner = self.inner.clone();
569        let mut guard = inner.lock().unwrap();
570        let env = guard
571            .as_mut()
572            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
573
574        let is_strict = env.is_strict();
575        let mut all_ontologies = HashSet::new();
576        let mut all_closure_names: Vec<String> = Vec::new();
577
578        for uri in &imports {
579            let iri = NamedNode::new(uri.as_str())
580                .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
581
582            let mut graphid = env.resolve(ResolveTarget::Graph(iri.clone()));
583
584            if graphid.is_none() && fetch_missing {
585                let location = OntologyLocation::from_str(uri.as_str()).map_err(anyhow_to_pyerr)?;
586                match env.add(location, Overwrite::Preserve, RefreshStrategy::UseCache) {
587                    Ok(new_id) => {
588                        graphid = Some(new_id);
589                    }
590                    Err(e) => {
591                        if is_strict {
592                            return Err(anyhow_to_pyerr(e));
593                        }
594                        println!("Failed to fetch {uri}: {e}");
595                    }
596                }
597            }
598
599            let graphid = match graphid {
600                Some(id) => id,
601                None => {
602                    if is_strict {
603                        return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
604                            "Failed to resolve graph for URI: {}",
605                            uri
606                        )));
607                    }
608                    println!("Could not find {uri:?}");
609                    continue;
610                }
611            };
612
613            let ont = env.ontologies().get(&graphid).ok_or_else(|| {
614                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
615                    "Ontology {} not found",
616                    uri
617                ))
618            })?;
619
620            let closure = env
621                .get_closure(ont.id(), recursion_depth)
622                .map_err(anyhow_to_pyerr)?;
623            for c_ont in closure {
624                all_closure_names.push(c_ont.to_uri_string());
625                all_ontologies.insert(c_ont.clone());
626            }
627        }
628
629        if all_ontologies.is_empty() {
630            return Ok(Vec::new());
631        }
632
633        let union = env
634            .get_union_graph(&all_ontologies, Some(true), Some(true))
635            .map_err(anyhow_to_pyerr)?;
636
637        for triple in union.dataset.into_iter() {
638            let s: Term = triple.subject.into();
639            let p: Term = triple.predicate.into();
640            let o: Term = triple.object.into();
641            let t = PyTuple::new(
642                py,
643                &[
644                    term_to_python(py, &rdflib, s)?,
645                    term_to_python(py, &rdflib, p)?,
646                    term_to_python(py, &rdflib, o)?,
647                ],
648            )?;
649            graph.getattr("add")?.call1((t,))?;
650        }
651
652        // Remove all owl:imports from the original graph
653        let py_imports_pred_for_remove = term_to_python(py, &rdflib, IMPORTS.into())?;
654        let remove_tuple = PyTuple::new(
655            py,
656            &[py.None(), py_imports_pred_for_remove.into(), py.None()],
657        )?;
658        graph.getattr("remove")?.call1((remove_tuple,))?;
659
660        all_closure_names.sort();
661        all_closure_names.dedup();
662
663        Ok(all_closure_names)
664    }
665
666    /// Get the dependency closure of a given graph and return it as a new graph.
667    ///
668    /// This method will look for `owl:imports` statements in the provided `graph`,
669    /// then find those ontologies within the `OntoEnv` and compute the full
670    /// dependency closure. The triples of all ontologies in the closure are
671    /// returned as a new graph. The original `graph` is left untouched unless you also
672    /// supply it as the `destination_graph`.
673    ///
674    /// Args:
675    ///     graph (rdflib.Graph): The graph to find dependencies for.
676    ///     destination_graph (Optional[rdflib.Graph]): If provided, the dependency graph will be added to this
677    ///         graph instead of creating a new one.
678    ///     recursion_depth (int): The maximum depth for recursive import resolution. A
679    ///         negative value (default) means no limit.
680    ///     fetch_missing (bool): If True, will fetch ontologies that are not in the environment.
681    ///     rewrite_sh_prefixes (bool): If True, will rewrite SHACL prefixes to be unique.
682    ///     remove_owl_imports (bool): If True, will remove `owl:imports` statements from the
683    ///         returned graph.
684    ///
685    /// Returns:
686    ///     tuple[rdflib.Graph, list[str]]: A tuple containing the populated dependency graph and the sorted list of
687    ///     imported ontology IRIs.
688    #[pyo3(signature = (graph, destination_graph=None, recursion_depth=-1, fetch_missing=false, rewrite_sh_prefixes=true, remove_owl_imports=true))]
689    fn get_dependencies_graph<'a>(
690        &self,
691        py: Python<'a>,
692        graph: &Bound<'a, PyAny>,
693        destination_graph: Option<&Bound<'a, PyAny>>,
694        recursion_depth: i32,
695        fetch_missing: bool,
696        rewrite_sh_prefixes: bool,
697        remove_owl_imports: bool,
698    ) -> PyResult<(Bound<'a, PyAny>, Vec<String>)> {
699        let rdflib = py.import("rdflib")?;
700        let py_imports_pred = term_to_python(py, &rdflib, Term::NamedNode(IMPORTS.into()))?;
701
702        let kwargs = [("predicate", py_imports_pred)].into_py_dict(py)?;
703        let objects_iter = graph.call_method("objects", (), Some(&kwargs))?;
704        let builtins = py.import("builtins")?;
705        let objects_list = builtins.getattr("list")?.call1((objects_iter,))?;
706        let imports: Vec<String> = objects_list.extract()?;
707
708        let destination_graph = match destination_graph {
709            Some(g) => g.clone(),
710            None => rdflib.getattr("Graph")?.call0()?,
711        };
712
713        if imports.is_empty() {
714            return Ok((destination_graph, Vec::new()));
715        }
716
717        let inner = self.inner.clone();
718        let mut guard = inner.lock().unwrap();
719        let env = guard
720            .as_mut()
721            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
722
723        let is_strict = env.is_strict();
724        let mut all_ontologies = HashSet::new();
725        let mut all_closure_names: Vec<String> = Vec::new();
726
727        for uri in &imports {
728            let iri = NamedNode::new(uri.as_str())
729                .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
730
731            let mut graphid = env.resolve(ResolveTarget::Graph(iri.clone()));
732
733            if graphid.is_none() && fetch_missing {
734                let location = OntologyLocation::from_str(uri.as_str()).map_err(anyhow_to_pyerr)?;
735                match env.add(location, Overwrite::Preserve, RefreshStrategy::UseCache) {
736                    Ok(new_id) => {
737                        graphid = Some(new_id);
738                    }
739                    Err(e) => {
740                        if is_strict {
741                            return Err(anyhow_to_pyerr(e));
742                        }
743                        println!("Failed to fetch {uri}: {e}");
744                    }
745                }
746            }
747
748            let graphid = match graphid {
749                Some(id) => id,
750                None => {
751                    if is_strict {
752                        return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
753                            "Failed to resolve graph for URI: {}",
754                            uri
755                        )));
756                    }
757                    println!("Could not find {uri:?}");
758                    continue;
759                }
760            };
761
762            let ont = env.ontologies().get(&graphid).ok_or_else(|| {
763                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
764                    "Ontology {} not found",
765                    uri
766                ))
767            })?;
768
769            let closure = env
770                .get_closure(ont.id(), recursion_depth)
771                .map_err(anyhow_to_pyerr)?;
772            for c_ont in closure {
773                all_closure_names.push(c_ont.to_uri_string());
774                all_ontologies.insert(c_ont.clone());
775            }
776        }
777
778        if all_ontologies.is_empty() {
779            return Ok((destination_graph, Vec::new()));
780        }
781
782        let union = env
783            .get_union_graph(
784                &all_ontologies,
785                Some(rewrite_sh_prefixes),
786                Some(remove_owl_imports),
787            )
788            .map_err(anyhow_to_pyerr)?;
789
790        for triple in union.dataset.into_iter() {
791            let s: Term = triple.subject.into();
792            let p: Term = triple.predicate.into();
793            let o: Term = triple.object.into();
794            let t = PyTuple::new(
795                py,
796                &[
797                    term_to_python(py, &rdflib, s)?,
798                    term_to_python(py, &rdflib, p)?,
799                    term_to_python(py, &rdflib, o)?,
800                ],
801            )?;
802            destination_graph.getattr("add")?.call1((t,))?;
803        }
804
805        if remove_owl_imports {
806            for graphid in union.graph_ids {
807                let iri = term_to_python(py, &rdflib, Term::NamedNode(graphid.into()))?;
808                let pred = term_to_python(py, &rdflib, IMPORTS.into())?;
809                let remove_tuple = PyTuple::new(py, &[py.None(), pred.into(), iri.into()])?;
810                destination_graph
811                    .getattr("remove")?
812                    .call1((remove_tuple,))?;
813            }
814        }
815
816        all_closure_names.sort();
817        all_closure_names.dedup();
818
819        Ok((destination_graph, all_closure_names))
820    }
821
822    /// Add a new ontology to the OntoEnv
823    #[pyo3(signature = (location, overwrite = false, fetch_imports = true, force = false))]
824    fn add(
825        &self,
826        location: &Bound<'_, PyAny>,
827        overwrite: bool,
828        fetch_imports: bool,
829        force: bool,
830    ) -> PyResult<String> {
831        let inner = self.inner.clone();
832        let mut guard = inner.lock().unwrap();
833        let env = guard
834            .as_mut()
835            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
836
837        let location =
838            OntologyLocation::from_str(&location.to_string()).map_err(anyhow_to_pyerr)?;
839        let overwrite_flag: Overwrite = overwrite.into();
840        let refresh: RefreshStrategy = force.into();
841        let graph_id = if fetch_imports {
842            env.add(location, overwrite_flag, refresh)
843        } else {
844            env.add_no_imports(location, overwrite_flag, refresh)
845        }
846        .map_err(anyhow_to_pyerr)?;
847        Ok(graph_id.to_uri_string())
848    }
849
850    /// Add a new ontology to the OntoEnv without exploring owl:imports.
851    #[pyo3(signature = (location, overwrite = false, force = false))]
852    fn add_no_imports(
853        &self,
854        location: &Bound<'_, PyAny>,
855        overwrite: bool,
856        force: bool,
857    ) -> PyResult<String> {
858        let inner = self.inner.clone();
859        let mut guard = inner.lock().unwrap();
860        let env = guard
861            .as_mut()
862            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
863        let location =
864            OntologyLocation::from_str(&location.to_string()).map_err(anyhow_to_pyerr)?;
865        let overwrite_flag: Overwrite = overwrite.into();
866        let refresh: RefreshStrategy = force.into();
867        let graph_id = env
868            .add_no_imports(location, overwrite_flag, refresh)
869            .map_err(anyhow_to_pyerr)?;
870        Ok(graph_id.to_uri_string())
871    }
872
873    /// Get the names of all ontologies that import the given ontology
874    fn get_importers(&self, uri: &str) -> PyResult<Vec<String>> {
875        let iri = NamedNode::new(uri)
876            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
877        let inner = self.inner.clone();
878        let guard = inner.lock().unwrap();
879        let env = guard
880            .as_ref()
881            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
882        let importers = env.get_importers(&iri).map_err(anyhow_to_pyerr)?;
883        let names: Vec<String> = importers.iter().map(|ont| ont.to_uri_string()).collect();
884        Ok(names)
885    }
886
887    /// Get the ontology metadata with the given URI
888    fn get_ontology(&self, uri: &str) -> PyResult<PyOntology> {
889        let iri = NamedNode::new(uri)
890            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
891        let inner = self.inner.clone();
892        let guard = inner.lock().unwrap();
893        let env = guard
894            .as_ref()
895            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
896        let graphid = env
897            .resolve(ResolveTarget::Graph(iri.clone()))
898            .ok_or_else(|| {
899                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
900                    "Failed to resolve graph for URI: {uri}"
901                ))
902            })?;
903        let ont = env.get_ontology(&graphid).map_err(anyhow_to_pyerr)?;
904        Ok(PyOntology { inner: ont })
905    }
906
907    /// Get the graph with the given URI as an rdflib.Graph
908    fn get_graph(&self, py: Python, uri: &Bound<'_, PyString>) -> PyResult<Py<PyAny>> {
909        let rdflib = py.import("rdflib")?;
910        let iri = NamedNode::new(uri.to_string())
911            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
912        let graph = {
913            let inner = self.inner.clone();
914            let guard = inner.lock().unwrap();
915            let env = guard.as_ref().ok_or_else(|| {
916                PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed")
917            })?;
918            let graphid = env.resolve(ResolveTarget::Graph(iri)).ok_or_else(|| {
919                PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
920                    "Failed to resolve graph for URI: {uri}"
921                ))
922            })?;
923
924            env.get_graph(&graphid).map_err(anyhow_to_pyerr)?
925        };
926        let res = rdflib.getattr("Graph")?.call0()?;
927        for triple in graph.into_iter() {
928            let s: Term = triple.subject.into();
929            let p: Term = triple.predicate.into();
930            let o: Term = triple.object.into();
931
932            let t = PyTuple::new(
933                py,
934                &[
935                    term_to_python(py, &rdflib, s)?,
936                    term_to_python(py, &rdflib, p)?,
937                    term_to_python(py, &rdflib, o)?,
938                ],
939            )?;
940
941            res.getattr("add")?.call1((t,))?;
942        }
943        Ok(res.into())
944    }
945
946    /// Get the names of all ontologies in the OntoEnv
947    fn get_ontology_names(&self) -> PyResult<Vec<String>> {
948        let inner = self.inner.clone();
949        let guard = inner.lock().unwrap();
950        let env = guard
951            .as_ref()
952            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
953        let names: Vec<String> = env.ontologies().keys().map(|k| k.to_uri_string()).collect();
954        Ok(names)
955    }
956
957    /// Convert the OntoEnv to an in-memory rdflib.Dataset populated with all named graphs
958    fn to_rdflib_dataset(&self, py: Python) -> PyResult<Py<PyAny>> {
959        let inner = self.inner.clone();
960        let guard = inner.lock().unwrap();
961        let env = guard
962            .as_ref()
963            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("OntoEnv is closed"))?;
964        let rdflib = py.import("rdflib")?;
965        let dataset_cls = rdflib.getattr("Dataset")?;
966        let ds = dataset_cls.call0()?;
967        let uriref = rdflib.getattr("URIRef")?;
968
969        for (_gid, ont) in env.ontologies().iter() {
970            let id_str = ont.id().name().as_str();
971            let id_py = uriref.call1((id_str,))?;
972            let kwargs = [("identifier", id_py.clone())].into_py_dict(py)?;
973            let ctx = ds.getattr("graph")?.call((), Some(&kwargs))?;
974
975            let graph = env.get_graph(ont.id()).map_err(anyhow_to_pyerr)?;
976            for t in graph.iter() {
977                let s: Term = t.subject.into();
978                let p: Term = t.predicate.into();
979                let o: Term = t.object.into();
980                let triple = PyTuple::new(
981                    py,
982                    &[
983                        term_to_python(py, &rdflib, s)?,
984                        term_to_python(py, &rdflib, p)?,
985                        term_to_python(py, &rdflib, o)?,
986                    ],
987                )?;
988                ctx.getattr("add")?.call1((triple,))?;
989            }
990        }
991
992        Ok(ds.into())
993    }
994
995    // Config accessors
996    fn is_offline(&self) -> PyResult<bool> {
997        let inner = self.inner.clone();
998        let guard = inner.lock().unwrap();
999        if let Some(env) = guard.as_ref() {
1000            Ok(env.is_offline())
1001        } else {
1002            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1003                "OntoEnv is closed",
1004            ))
1005        }
1006    }
1007
1008    fn set_offline(&mut self, offline: bool) -> PyResult<()> {
1009        let inner = self.inner.clone();
1010        let mut guard = inner.lock().unwrap();
1011        if let Some(env) = guard.as_mut() {
1012            env.set_offline(offline);
1013            env.save_to_directory().map_err(anyhow_to_pyerr)
1014        } else {
1015            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1016                "OntoEnv is closed",
1017            ))
1018        }
1019    }
1020
1021    fn is_strict(&self) -> PyResult<bool> {
1022        let inner = self.inner.clone();
1023        let guard = inner.lock().unwrap();
1024        if let Some(env) = guard.as_ref() {
1025            Ok(env.is_strict())
1026        } else {
1027            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1028                "OntoEnv is closed",
1029            ))
1030        }
1031    }
1032
1033    fn set_strict(&mut self, strict: bool) -> PyResult<()> {
1034        let inner = self.inner.clone();
1035        let mut guard = inner.lock().unwrap();
1036        if let Some(env) = guard.as_mut() {
1037            env.set_strict(strict);
1038            env.save_to_directory().map_err(anyhow_to_pyerr)
1039        } else {
1040            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1041                "OntoEnv is closed",
1042            ))
1043        }
1044    }
1045
1046    fn requires_ontology_names(&self) -> PyResult<bool> {
1047        let inner = self.inner.clone();
1048        let guard = inner.lock().unwrap();
1049        if let Some(env) = guard.as_ref() {
1050            Ok(env.requires_ontology_names())
1051        } else {
1052            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1053                "OntoEnv is closed",
1054            ))
1055        }
1056    }
1057
1058    fn set_require_ontology_names(&mut self, require: bool) -> PyResult<()> {
1059        let inner = self.inner.clone();
1060        let mut guard = inner.lock().unwrap();
1061        if let Some(env) = guard.as_mut() {
1062            env.set_require_ontology_names(require);
1063            env.save_to_directory().map_err(anyhow_to_pyerr)
1064        } else {
1065            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1066                "OntoEnv is closed",
1067            ))
1068        }
1069    }
1070
1071    fn no_search(&self) -> PyResult<bool> {
1072        let inner = self.inner.clone();
1073        let guard = inner.lock().unwrap();
1074        if let Some(env) = guard.as_ref() {
1075            Ok(env.no_search())
1076        } else {
1077            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1078                "OntoEnv is closed",
1079            ))
1080        }
1081    }
1082
1083    fn set_no_search(&mut self, no_search: bool) -> PyResult<()> {
1084        let inner = self.inner.clone();
1085        let mut guard = inner.lock().unwrap();
1086        if let Some(env) = guard.as_mut() {
1087            env.set_no_search(no_search);
1088            env.save_to_directory().map_err(anyhow_to_pyerr)
1089        } else {
1090            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1091                "OntoEnv is closed",
1092            ))
1093        }
1094    }
1095
1096    fn resolution_policy(&self) -> PyResult<String> {
1097        let inner = self.inner.clone();
1098        let guard = inner.lock().unwrap();
1099        if let Some(env) = guard.as_ref() {
1100            Ok(env.resolution_policy().to_string())
1101        } else {
1102            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1103                "OntoEnv is closed",
1104            ))
1105        }
1106    }
1107
1108    fn set_resolution_policy(&mut self, policy: String) -> PyResult<()> {
1109        let inner = self.inner.clone();
1110        let mut guard = inner.lock().unwrap();
1111        if let Some(env) = guard.as_mut() {
1112            env.set_resolution_policy(policy);
1113            env.save_to_directory().map_err(anyhow_to_pyerr)
1114        } else {
1115            Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1116                "OntoEnv is closed",
1117            ))
1118        }
1119    }
1120
1121    pub fn store_path(&self) -> PyResult<Option<String>> {
1122        let inner = self.inner.clone();
1123        let guard = inner.lock().unwrap();
1124        if let Some(env) = guard.as_ref() {
1125            match env.store_path() {
1126                Some(path) => {
1127                    let dir = path.parent().unwrap_or(path);
1128                    Ok(Some(dir.to_string_lossy().to_string()))
1129                }
1130                None => Ok(None), // Return None if the path doesn't exist (e.g., temporary env)
1131            }
1132        } else {
1133            Ok(None)
1134        }
1135    }
1136
1137    // Wrapper method to raise error if store_path is None, matching previous panic behavior
1138    // but providing a Python-level error. Or tests can check for None.
1139    // Let's keep the Option return type for flexibility and adjust tests.
1140
1141    pub fn close(&mut self, py: Python<'_>) -> PyResult<()> {
1142        py.allow_threads(|| {
1143            let inner = self.inner.clone();
1144            let mut guard = inner.lock().unwrap();
1145            if let Some(env) = guard.as_mut() {
1146                env.save_to_directory().map_err(anyhow_to_pyerr)?;
1147                env.flush().map_err(anyhow_to_pyerr)?;
1148            }
1149            *guard = None;
1150            Ok(())
1151        })
1152    }
1153
1154    pub fn flush(&mut self, py: Python<'_>) -> PyResult<()> {
1155        py.allow_threads(|| {
1156            let inner = self.inner.clone();
1157            let mut guard = inner.lock().unwrap();
1158            if let Some(env) = guard.as_mut() {
1159                env.flush().map_err(anyhow_to_pyerr)
1160            } else {
1161                Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
1162                    "OntoEnv is closed",
1163                ))
1164            }
1165        })
1166    }
1167}
1168
1169#[pymodule]
1170fn _native(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
1171    // Initialize logging when the python module is loaded.
1172    ::ontoenv::api::init_logging();
1173    // Use try_init to avoid panic if logging is already initialized.
1174    let _ = env_logger::try_init();
1175
1176    m.add_class::<OntoEnv>()?;
1177    m.add_class::<PyOntology>()?;
1178    m.add_function(wrap_pyfunction!(run_cli, m)?)?;
1179    // add version attribute
1180    m.add("version", env!("CARGO_PKG_VERSION"))?;
1181    Ok(())
1182}