ontoenv_python/
lib.rs

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