diff --git a/CMakeLists.txt b/CMakeLists.txt index bfe561e1..b6f5930f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ set(${PROJECT_NAME}_HEADERS include/pyhpp/core/fwd.hh include/pyhpp/core/path/fwd.hh include/pyhpp/core/path-planner.hh + include/pyhpp/core/path-validation.hh include/pyhpp/core/pathOptimization/fwd.hh include/pyhpp/core/problem.hh include/pyhpp/core/problemTarget/fwd.hh diff --git a/doc/doxygen_xml_parser.py b/doc/doxygen_xml_parser.py index 26812e70..85148562 100644 --- a/doc/doxygen_xml_parser.py +++ b/doc/doxygen_xml_parser.py @@ -34,7 +34,7 @@ def _getDoc(el): b = el.find("briefdescription") d = el.find("detaileddescription") brief = etree.tostring(b, method="text", encoding="unicode").strip() - detailed = d.text.strip() if d.text else "" + detailed = etree.tostring(d, method="text", encoding="unicode").strip() return brief, detailed def _getMemberFromGroup(self, memberDefKind, name): @@ -124,11 +124,7 @@ def _getMember(self, sectionKind, memberDefKind, name): raise IndexError(msg) def getClassDoc(self): - b_el = self.compound.find("briefdescription") - d_el = self.compound.find("detaileddescription") - brief = etree.tostring(b_el, method="text", encoding="unicode").strip() - detailed = etree.tostring(d_el, method="text", encoding="unicode").strip() - return brief, detailed + return self._getDoc(self.compound) def getClassMemberDoc(self, membername): # member = self.compound.find ("sectiondef[@kind='public-attrib']/memberdef[@kind='variable' and name='" + methodname + "']") diff --git a/doc/gen_api_md.py b/doc/gen_api_md.py index cc1df883..498227ee 100644 --- a/doc/gen_api_md.py +++ b/doc/gen_api_md.py @@ -294,6 +294,11 @@ def _unparse_ann(ann: ast.expr | None) -> str: # --------------------------------------------------------------------------- ClassIndex = dict[str, str] # type name variants → "page.md#anchor" +ClassDefinitionIndex = dict[str, ast.ClassDef] + + +def _stub_module_name(relative_stub: str) -> str: + return str(Path(relative_stub).with_suffix("")).replace("/", ".") def build_class_index(stubs_root: Path) -> ClassIndex: @@ -307,7 +312,7 @@ def build_class_index(stubs_root: Path) -> ClassIndex: if tree is None: continue page = slug(module_name) - stub_dotted = str(Path(rel_stub).with_suffix("")).replace("/", ".") + stub_dotted = _stub_module_name(rel_stub) for item in tree.body: if not isinstance(item, ast.ClassDef): continue @@ -334,6 +339,30 @@ def build_class_index(stubs_root: Path) -> ClassIndex: return index +def build_class_definition_index(stubs_root: Path) -> ClassDefinitionIndex: + index: ClassDefinitionIndex = {} + + def add_class(class_node: ast.ClassDef, parent: str) -> None: + qualified_class = f"{parent}.{class_node.name}" + index[qualified_class] = class_node + for item in class_node.body: + if isinstance(item, ast.ClassDef): + add_class(item, qualified_class) + + for rel_stub, _, _ in MODULES: + stub_path = stubs_root / rel_stub + if not stub_path.exists(): + continue + tree = _parse_stub(stub_path) + if tree is None: + continue + stub_module = _stub_module_name(rel_stub) + for item in tree.body: + if isinstance(item, ast.ClassDef): + add_class(item, stub_module) + return index + + def _type_link(type_str: str, class_index: ClassIndex, current_page: str) -> str: """Markdown link for a type (used outside code blocks, e.g. base-class line).""" if not type_str: @@ -410,11 +439,30 @@ def _fmt_ann(ann: str, class_index: ClassIndex, current_page: str) -> str: def _build_stub_module_index() -> dict[str, str]: index: dict[str, str] = {} for rel_stub, module_name, _ in MODULES: - dotted = str(Path(rel_stub).with_suffix("")).replace("/", ".") - index[dotted] = slug(module_name) + index[_stub_module_name(rel_stub)] = slug(module_name) return index +def _base_name(base: ast.expr) -> str: + if isinstance(base, ast.Attribute): + return ast.unparse(base) + if isinstance(base, ast.Name): + return base.id + return "" + + +def _resolve_base( + base: ast.expr, + current_stub_module: str, + class_definitions: ClassDefinitionIndex, +) -> str | None: + raw = _base_name(base) + if not raw: + return None + qualified = raw if "." in raw else f"{current_stub_module}.{raw}" + return qualified if qualified in class_definitions else None + + def _base_links( class_node: ast.ClassDef, stub_module_index: dict[str, str], @@ -423,11 +471,7 @@ def _base_links( ) -> list[str]: links = [] for b in class_node.bases: - raw = ( - ast.unparse(b) - if isinstance(b, ast.Attribute) - else (b.id if isinstance(b, ast.Name) else "") - ) + raw = _base_name(b) if not raw: continue class_name = raw.split(".")[-1] @@ -449,6 +493,7 @@ def _base_links( # --------------------------------------------------------------------------- type BodyGroup = list[ast.FunctionDef] | ast.ClassDef +type ClassMethods = dict[str, tuple[list[ast.FunctionDef], str]] def _group_body(body: list[ast.stmt]) -> list[BodyGroup]: @@ -473,6 +518,80 @@ def _group_body(body: list[ast.stmt]) -> list[BodyGroup]: return result +def _class_mro( + qualified_class: str, + class_definitions: ClassDefinitionIndex, + cache: dict[str, list[str]], +) -> list[str]: + if qualified_class in cache: + return cache[qualified_class] + + class_node = class_definitions[qualified_class] + current_stub_module = qualified_class.rsplit(".", 1)[0] + bases = [ + resolved + for base in class_node.bases + if (resolved := _resolve_base(base, current_stub_module, class_definitions)) + ] + sequences = [_class_mro(base, class_definitions, cache).copy() for base in bases] + sequences.append(bases.copy()) + + result = [qualified_class] + while sequences: + sequences = [sequence for sequence in sequences if sequence] + if not sequences: + break + candidate = next( + ( + sequence[0] + for sequence in sequences + if not any(sequence[0] in other[1:] for other in sequences) + ), + None, + ) + if candidate is None: + raise ValueError(f"Inconsistent class hierarchy for {qualified_class}") + result.append(candidate) + for sequence in sequences: + if sequence[0] == candidate: + sequence.pop(0) + + cache[qualified_class] = result + return result + + +def _direct_method_groups( + class_node: ast.ClassDef, +) -> dict[str, list[ast.FunctionDef]]: + methods: dict[str, list[ast.FunctionDef]] = {} + for group in _group_body(class_node.body): + if isinstance(group, ast.ClassDef): + continue + node = group[0] + methods.setdefault(node.name, []).extend(group) + return methods + + +def _collect_class_methods( + qualified_class: str, + class_definitions: ClassDefinitionIndex, + cache: dict[str, ClassMethods], + mro_cache: dict[str, list[str]], +) -> ClassMethods: + if qualified_class in cache: + return cache[qualified_class] + + methods: ClassMethods = {} + for owner in _class_mro(qualified_class, class_definitions, mro_cache): + for name, group in _direct_method_groups(class_definitions[owner]).items(): + if name == "__init__" and owner != qualified_class: + continue + methods.setdefault(name, (group, owner)) + + cache[qualified_class] = methods + return methods + + # --------------------------------------------------------------------------- # Signature rendering — HTML
 with embedded  links
 # ---------------------------------------------------------------------------
@@ -664,6 +783,8 @@ def render_class(
     class_index: ClassIndex,
     current_page: str,
     stub_module_index: dict[str, str],
+    qualified_class: str,
+    class_methods: dict[str, ClassMethods],
     heading_level: int = 2,
 ) -> tuple[list[str], bool]:
     """Render a class and return (lines, has_content)."""
@@ -679,22 +800,37 @@ def render_class(
     for g in groups:
         if isinstance(g, ast.ClassDef):
             sub_lines, _ = render_class(
-                g, class_index, current_page, stub_module_index, heading_level + 1
+                g,
+                class_index,
+                current_page,
+                stub_module_index,
+                f"{qualified_class}.{g.name}",
+                class_methods,
+                heading_level + 1,
             )
             nested_class_lines.extend(sub_lines)
-        else:
-            node = g[0]
-            if node.name in SKIP_NAMES or is_setter(node):
+
+    methods = class_methods.get(qualified_class)
+    if methods is None:
+        methods = {
+            name: (group, qualified_class)
+            for name, group in _direct_method_groups(class_node).items()
+        }
+    for group, owner in methods.values():
+        node = group[0]
+        if node.name in SKIP_NAMES or is_setter(node):
+            continue
+        if node.name == "__init__":
+            if any(
+                get_docstring(ov) and NOT_INSTANTIABLE.search(get_docstring(ov))
+                for ov in group
+            ):
                 continue
-            if node.name == "__init__":
-                if any(
-                    get_docstring(ov) and NOT_INSTANTIABLE.search(get_docstring(ov))
-                    for ov in g
-                ):
-                    continue
-            sig = _sig_cell(g, class_index, current_page)
-            desc = _desc_cell(g)
-            method_rows.append((sig, desc))
+        sig = _sig_cell(group, class_index, current_page)
+        if owner != qualified_class:
+            sig += f" *(inherited from {_type_link(owner, class_index, current_page)})*"
+        desc = _desc_cell(group)
+        method_rows.append((sig, desc))
 
     has_content = bool(raw_doc or bases or method_rows or nested_class_lines)
     if not has_content:
@@ -744,8 +880,10 @@ def render_class(
 def generate_module_md(
     stub_path: Path,
     module_name: str,
+    current_stub_module: str,
     class_index: ClassIndex,
     stub_module_index: dict[str, str],
+    class_methods: dict[str, ClassMethods],
 ) -> str:
     tree = _parse_stub(stub_path)
     if tree is None:
@@ -791,7 +929,12 @@ def generate_module_md(
     for item in tree.body:
         if isinstance(item, ast.ClassDef):
             class_lines, ok = render_class(
-                item, class_index, current_page, stub_module_index
+                item,
+                class_index,
+                current_page,
+                stub_module_index,
+                f"{current_stub_module}.{item.name}",
+                class_methods,
             )
             if ok:
                 lines.extend(class_lines)
@@ -828,7 +971,14 @@ def main() -> None:
     args.output.mkdir(parents=True, exist_ok=True)
 
     class_index = build_class_index(args.stubs)
+    class_definitions = build_class_definition_index(args.stubs)
     stub_module_index = _build_stub_module_index()
+    class_methods: dict[str, ClassMethods] = {}
+    mro_cache: dict[str, list[str]] = {}
+    for qualified_class in class_definitions:
+        _collect_class_methods(
+            qualified_class, class_definitions, class_methods, mro_cache
+        )
 
     generated: list[tuple[str, str, str]] = []
     for rel_stub, module_name, description in MODULES:
@@ -838,7 +988,14 @@ def main() -> None:
             continue
         out_file = args.output / f"{slug(module_name)}.md"
         out_file.write_text(
-            generate_module_md(stub_path, module_name, class_index, stub_module_index)
+            generate_module_md(
+                stub_path,
+                module_name,
+                _stub_module_name(rel_stub),
+                class_index,
+                stub_module_index,
+                class_methods,
+            )
         )
         generated.append((module_name, slug(module_name), description))
         print(f"  {out_file.name}")
diff --git a/include/pyhpp/core/fwd.hh b/include/pyhpp/core/fwd.hh
index 64cc175c..36bebba0 100644
--- a/include/pyhpp/core/fwd.hh
+++ b/include/pyhpp/core/fwd.hh
@@ -61,7 +61,9 @@ void exposeProblem();
 
 // forward declaration of some classes
 class PathPlanner;
+struct PathValidation;
 class Problem;
+typedef std::shared_ptr PyWPathValidationPtr_t;
 }  // namespace core
 }  // namespace pyhpp
 
diff --git a/include/pyhpp/core/path-validation.hh b/include/pyhpp/core/path-validation.hh
new file mode 100644
index 00000000..f20f8c3f
--- /dev/null
+++ b/include/pyhpp/core/path-validation.hh
@@ -0,0 +1,64 @@
+//
+// Copyright (c) 2026 CNRS
+// Author: Paul Sardin
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions
+// are met:
+
+// 1. Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+
+// 2. Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following
+// disclaimer in the documentation and/or other materials provided
+// with the distribution.
+
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+// OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#ifndef PYHPP_CORE_PATH_VALIDATION_HH
+#define PYHPP_CORE_PATH_VALIDATION_HH
+
+#include 
+#include 
+#include 
+
+namespace pyhpp {
+namespace core {
+
+struct PathValidation {
+  hpp::core::PathValidationPtr_t obj;
+  hpp::core::PathValidationBuilder_t factory;
+  hpp::core::value_type tolerance;
+
+  PathValidation(const hpp::core::PathValidationPtr_t& obj)
+      : obj(obj), factory(), tolerance(0) {}
+
+  PathValidation(const hpp::core::DevicePtr_t& robot,
+                 const hpp::core::PathValidationBuilder_t& factory,
+                 const hpp::core::value_type& tolerance)
+      : obj(factory(robot, tolerance)),
+        factory(factory),
+        tolerance(tolerance) {}
+
+  PathValidation(const hpp::core::PathValidationPtr_t& obj,
+                 const hpp::core::PathValidationBuilder_t& factory,
+                 const hpp::core::value_type& tolerance)
+      : obj(obj), factory(factory), tolerance(tolerance) {}
+};
+
+}  // namespace core
+}  // namespace pyhpp
+
+#endif  // PYHPP_CORE_PATH_VALIDATION_HH
diff --git a/include/pyhpp/core/problem.hh b/include/pyhpp/core/problem.hh
index 8d79f967..c582e23f 100644
--- a/include/pyhpp/core/problem.hh
+++ b/include/pyhpp/core/problem.hh
@@ -73,6 +73,7 @@ struct Problem {
   PyWSteeringMethodPtr_t steeringMethod() const;
   const ConfigValidationsPtr_t& configValidation() const;
   PathValidationPtr_t pathValidation() const;
+  PyWPathValidationPtr_t pyPathValidation() const;
   PathProjectorPtr_t pathProjector() const;
   DistancePtr_t distance() const;
   const ProblemTargetPtr_t& target() const;
@@ -81,6 +82,7 @@ struct Problem {
   void configValidation(const ConfigValidationsPtr_t& cv);
   void clearConfigValidations();
   void pathValidation(const PathValidationPtr_t& pv);
+  void pyPathValidation(const PyWPathValidationPtr_t& pv);
   void pathProjector(const PathProjectorPtr_t& pp);
   void distance(const DistancePtr_t& d);
   void target(const ProblemTargetPtr_t& t);
@@ -146,6 +148,7 @@ struct Problem {
   boost::python::tuple directPath(ConfigurationIn_t start,
                                   ConfigurationIn_t end, bool validate);
   hpp::core::ConstraintSetPtr_t constraints_;
+  PyWPathValidationPtr_t pathValidation_;
   value_type errorThreshold_;
   size_type maxIterProjection_;
 };
diff --git a/src/pyhpp/core/path-planner.cc b/src/pyhpp/core/path-planner.cc
index 32526847..b37f6796 100644
--- a/src/pyhpp/core/path-planner.cc
+++ b/src/pyhpp/core/path-planner.cc
@@ -55,27 +55,70 @@ using namespace boost::python;
 
 namespace pathPlanner {
 
-#define DEFINE_PLANNER_WRAPPER(WrapperName, PlannerType, PlannerPtr) \
-  struct WrapperName : public pyhpp::core::PathPlanner {             \
-    WrapperName(const pyhpp::core::Problem& problem) {               \
-      obj = PlannerType::create(problem.obj);                        \
-    }                                                                \
-  };                                                                 \
-  void expose##WrapperName() {                                       \
-    class_>(            \
-        #WrapperName, init());          \
+#define DEFINE_PLANNER_WRAPPER(WrapperName, PlannerType) \
+  struct WrapperName : public pyhpp::core::PathPlanner { \
+    WrapperName(const pyhpp::core::Problem& problem) {   \
+      obj = PlannerType::create(problem.obj);            \
+    }                                                    \
   }
 
-DEFINE_PLANNER_WRAPPER(DiffusingPlanner, hpp::core::DiffusingPlanner,
-                       hpp::core::DiffusingPlannerPtr_t)
-DEFINE_PLANNER_WRAPPER(BiRRTPlanner, hpp::core::BiRRTPlanner,
-                       hpp::core::BiRRTPlannerPtr_t)
-DEFINE_PLANNER_WRAPPER(VisibilityPrmPlanner, hpp::core::VisibilityPrmPlanner,
-                       hpp::core::VisibilityPrmPlannerPtr_t)
-DEFINE_PLANNER_WRAPPER(BiRrtStar, hpp::core::pathPlanner::BiRrtStar,
-                       hpp::core::pathPlanner::BiRrtStarPtr_t)
-DEFINE_PLANNER_WRAPPER(kPrmStar, hpp::core::pathPlanner::kPrmStar,
-                       hpp::core::pathPlanner::kPrmStarPtr_t)
+DEFINE_PLANNER_WRAPPER(DiffusingPlanner, hpp::core::DiffusingPlanner);
+DEFINE_PLANNER_WRAPPER(BiRRTPlanner, hpp::core::BiRRTPlanner);
+DEFINE_PLANNER_WRAPPER(VisibilityPrmPlanner, hpp::core::VisibilityPrmPlanner);
+DEFINE_PLANNER_WRAPPER(BiRrtStar, hpp::core::pathPlanner::BiRrtStar);
+DEFINE_PLANNER_WRAPPER(kPrmStar, hpp::core::pathPlanner::kPrmStar);
+
+// DocClass(DiffusingPlanner)
+void exposeDiffusingPlanner() {
+  class_>(
+      "DiffusingPlanner", DocClassDoc(), init())
+      .def("startSolve", &pyhpp::core::PathPlanner::startSolve,
+           DocClassMethod(startSolve))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
+}
+
+// DocClass(BiRRTPlanner)
+void exposeBiRRTPlanner() {
+  class_>(
+      "BiRRTPlanner", DocClassDoc(), init())
+      .def("startSolve", &pyhpp::core::PathPlanner::startSolve,
+           DocClassMethod(startSolve))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
+}
+
+// DocClass(VisibilityPrmPlanner)
+void exposeVisibilityPrmPlanner() {
+  class_>(
+      "VisibilityPrmPlanner", DocClassDoc(),
+      init())
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
+}
+
+// DocClass(pathPlanner::BiRrtStar)
+void exposeBiRrtStar() {
+  class_>(
+      "BiRrtStar", DocClassDoc(), init())
+      .def("startSolve", &pyhpp::core::PathPlanner::startSolve,
+           DocClassMethod(startSolve))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
+}
+
+// DocClass(pathPlanner::kPrmStar)
+void exposekPrmStar() {
+  class_>(
+      "kPrmStar", DocClassDoc(), init())
+      .def("startSolve", &pyhpp::core::PathPlanner::startSolve,
+           DocClassMethod(startSolve))
+      .def("tryConnectInitAndGoals",
+           &pyhpp::core::PathPlanner::tryConnectInitAndGoals,
+           DocClassMethod(tryConnectInitAndGoals))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
+}
 
 struct SearchInRoadmap : public pyhpp::core::PathPlanner {
   SearchInRoadmap(const pyhpp::core::Problem& problem,
@@ -85,10 +128,16 @@ struct SearchInRoadmap : public pyhpp::core::PathPlanner {
   }
 };
 
+// DocClass(pathPlanner::SearchInRoadmap)
 void exposeSearchInRoadmap() {
   class_>(
-      "SearchInRoadmap",
-      init());
+      "SearchInRoadmap", DocClassDoc(),
+      init())
+      .def("tryConnectInitAndGoals",
+           &pyhpp::core::PathPlanner::tryConnectInitAndGoals,
+           DocClassMethod(tryConnectInitAndGoals))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep));
 }
 
 struct PlanAndOptimize : public pyhpp::core::PathPlanner {
@@ -97,9 +146,16 @@ struct PlanAndOptimize : public pyhpp::core::PathPlanner {
   }
 };
 
+// DocClass(PlanAndOptimize)
 void exposePlanAndOptimize() {
   class_>(
-      "PlanAndOptimize", init());
+      "PlanAndOptimize", DocClassDoc(), init())
+      .def("startSolve", &pyhpp::core::PathPlanner::startSolve,
+           DocClassMethod(startSolve))
+      .def("oneStep", &pyhpp::core::PathPlanner::oneStep,
+           DocClassMethod(oneStep))
+      .def("finishSolve", &pyhpp::core::PathPlanner::finishSolve,
+           DocClassMethod(finishSolve));
 }
 
 void exposePathPlanners() {
diff --git a/src/pyhpp/core/path-validation.cc b/src/pyhpp/core/path-validation.cc
index ab8f8647..6daf7dd8 100644
--- a/src/pyhpp/core/path-validation.cc
+++ b/src/pyhpp/core/path-validation.cc
@@ -1,6 +1,6 @@
 //
-// Copyright (c) 2018 - 2023, CNRS
-// Authors: Joseph Mirabel, Florent Lamiraux
+// Copyright (c) 2018 - 2026, CNRS
+// Authors: Joseph Mirabel, Florent Lamiraux, Paul Sardin
 //
 // Redistribution and use in source and binary forms, with or without
 // modification, are permitted provided that the following conditions
@@ -33,108 +33,163 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
-#include 
-#include 
+#include 
+#include 
 #include 
 // DocNamespace(hpp::core)
 
-using namespace boost::python;
-
 namespace pyhpp {
 namespace core {
-using namespace hpp::core;
+
+using namespace boost::python;
+
+namespace {
+
+hpp::core::PathValidationPtr_t createDiscretizedJointBoundAndCollisionChecking(
+    const hpp::core::DevicePtr_t& robot,
+    const hpp::core::value_type& stepSize) {
+  return hpp::core::pathValidation::Discretized::create(
+      stepSize, {hpp::core::JointBoundValidation::create(robot),
+                 hpp::core::CollisionValidation::create(robot)});
+}
+
+const hpp::core::PathValidationBuilder_t& noValidationFactory() {
+  static const hpp::core::PathValidationBuilder_t factory =
+      hpp::core::ProblemSolver::create()->pathValidations.get("NoValidation");
+  return factory;
+}
 
 struct PVWrapper {
-  static bool validate(PathValidation* pv, const PathPtr_t path, bool reverse,
-                       PathPtr_t& validPart,
-                       PathValidationReportPtr_t& report) {
-    return pv->validate(path, reverse, validPart, report);
+  static bool validate(PathValidation* pv, const hpp::core::PathPtr_t path,
+                       bool reverse, hpp::core::PathPtr_t& validPart,
+                       hpp::core::PathValidationReportPtr_t& report) {
+    return pv->obj->validate(path, reverse, validPart, report);
   }
 
-  static tuple py_validate(PathValidation* pv, const PathPtr_t path,
-                           bool reverse = false) {
-    PathPtr_t validPart;
-    PathValidationReportPtr_t report;
-    bool res = pv->validate(path, reverse, validPart, report);
-    return boost::python::make_tuple(res, validPart, report);
-  }
-  static tuple validateConfiguration(PathValidation* pv, ConfigurationIn_t q) {
-    ValidationReportPtr_t report;
-    bool res = pv->validate(q, report);
-    return boost::python::make_tuple(res, report);
+  static tuple pyValidate(PathValidation* pv, const hpp::core::PathPtr_t path,
+                          bool reverse = false) {
+    hpp::core::PathPtr_t validPart;
+    hpp::core::PathValidationReportPtr_t report;
+    bool result = pv->obj->validate(path, reverse, validPart, report);
+    return boost::python::make_tuple(result, validPart, report);
   }
 
-  static pathValidation::DiscretizedPtr_t
-  createDiscretizedJointBoundAndCollisionChecking(const DevicePtr_t& robot,
-                                                  const value_type& stepSize) {
-    using namespace pathValidation;
-    return Discretized::create(stepSize,
-                               {
-                                   JointBoundValidation::create(robot),
-                                   CollisionValidation::create(robot),
-                               });
+  static tuple validateConfiguration(PathValidation* pv,
+                                     hpp::core::ConfigurationIn_t q) {
+    hpp::core::ValidationReportPtr_t report;
+    bool result = pv->obj->validate(q, report);
+    return boost::python::make_tuple(result, report);
   }
 };
+namespace pathValidation {
+
+struct NoValidation : PathValidation {
+  NoValidation(const hpp::core::DevicePtr_t& robot,
+               const hpp::core::value_type& tolerance)
+      : PathValidation(robot, noValidationFactory(), tolerance) {}
+};
+
+struct Discretized : PathValidation {
+  Discretized(const hpp::core::DevicePtr_t& robot,
+              const hpp::core::value_type& stepSize)
+      : PathValidation(
+            robot,
+            hpp::core::pathValidation::createDiscretizedCollisionChecking,
+            stepSize) {}
+};
+
+struct DiscretizedCollision : PathValidation {
+  DiscretizedCollision(const hpp::core::DevicePtr_t& robot,
+                       const hpp::core::value_type& stepSize)
+      : PathValidation(
+            robot,
+            hpp::core::pathValidation::createDiscretizedCollisionChecking,
+            stepSize) {}
+};
+
+struct DiscretizedJointBound : PathValidation {
+  DiscretizedJointBound(const hpp::core::DevicePtr_t& robot,
+                        const hpp::core::value_type& stepSize)
+      : PathValidation(robot,
+                       hpp::core::pathValidation::createDiscretizedJointBound,
+                       stepSize) {}
+};
+
+struct DiscretizedCollisionAndJointBound : PathValidation {
+  DiscretizedCollisionAndJointBound(const hpp::core::DevicePtr_t& robot,
+                                    const hpp::core::value_type& stepSize)
+      : PathValidation(robot, createDiscretizedJointBoundAndCollisionChecking,
+                       stepSize) {}
+};
+
+struct Progressive : PathValidation {
+  Progressive(const hpp::core::DevicePtr_t& robot,
+              const hpp::core::value_type& tolerance)
+      : PathValidation(robot,
+                       hpp::core::continuousValidation::Progressive::create,
+                       tolerance) {}
+};
+
+struct Dichotomy : PathValidation {
+  Dichotomy(const hpp::core::DevicePtr_t& robot,
+            const hpp::core::value_type& tolerance)
+      : PathValidation(robot,
+                       hpp::core::continuousValidation::Dichotomy::create,
+                       tolerance) {}
+};
+
+}  // namespace pathValidation
+}  // namespace
+
 void exposePathValidation() {
+  register_ptr_to_python();
+
   // DocClass(PathValidation)
-  class_(
+  class_(
       "PathValidation", DocClassDoc(), no_init)
       .def("validate", &PVWrapper::validate, DocClassMethod(validate))
-      .def("validate", &PVWrapper::py_validate,
+      .def("validate", &PVWrapper::pyValidate,
            "Validate path; returns (valid, validPart, report).")
       .def("validateConfiguration", &PVWrapper::validateConfiguration,
            "Validate a configuration; returns (valid, report).");
 
-  class_,
-         hpp::core::pathValidation::DiscretizedPtr_t, boost::noncopyable>(
-      "Discretized", DocClassDoc(), no_init)
-      .def("__init__",
-           make_constructor(
-               +[](const DevicePtr_t& robot, const value_type& stepSize) {
-                 return pathValidation::createDiscretizedCollisionChecking(
-                     robot, stepSize);
-               },
-               default_call_policies(), (arg("robot"), arg("stepSize"))),
-           "Create a discretized collision-checking path validation.");
-
-  hpp::core::continuousValidation::ProgressivePtr_t (*ProgressiveConstructor)(
-      const DevicePtr_t&, const value_type&) =
-      &continuousValidation::Progressive::create;
-  class_,
-         hpp::core::continuousValidation::ProgressivePtr_t, boost::noncopyable>(
-      "Progressive", DocClassDoc(), no_init)
-      .def("__init__",
-           make_constructor(ProgressiveConstructor, default_call_policies(),
-                            (arg("robot"), arg("tolerance"))),
-           "Create a progressive continuous path validation.");
-
-  hpp::core::continuousValidation::DichotomyPtr_t (*DichotomyConstructor)(
-      const DevicePtr_t&, const value_type&) =
-      &continuousValidation::Dichotomy::create;
-  class_,
-         hpp::core::continuousValidation::DichotomyPtr_t, boost::noncopyable>(
-      "Dichotomy", DocClassDoc(), no_init)
-      .def("__init__",
-           make_constructor(DichotomyConstructor, default_call_policies(),
-                            (arg("robot"), arg("tolerance"))),
-           "Create a dichotomy-based continuous path validation.");
-
-  def("DiscretizedCollision",
-      &pathValidation::createDiscretizedCollisionChecking,
-      (arg("robot"), arg("stepSize")),
-      "Create a discretized collision-checking path validation.");
-  def("DiscretizedJointBound", &pathValidation::createDiscretizedJointBound,
-      (arg("robot"), arg("stepSize")),
-      "Create a discretized joint-bound path validation.");
-  def("DiscretizedCollisionAndJointBound",
-      &PVWrapper::createDiscretizedJointBoundAndCollisionChecking,
-      (arg("robot"), arg("stepSize")),
+  class_>(
+      "NoValidation", "Create a path validation that accepts every path.",
+      init(
+          (arg("robot"), arg("tolerance"))));
+  class_>(
+      "Discretized", "Create a discretized collision-checking path validation.",
+      init(
+          (arg("robot"), arg("stepSize"))));
+  class_>(
+      "DiscretizedCollision",
+      "Create a discretized collision-checking path validation.",
+      init(
+          (arg("robot"), arg("stepSize"))));
+  class_>(
+      "DiscretizedJointBound",
+      "Create a discretized joint-bound path validation.",
+      init(
+          (arg("robot"), arg("stepSize"))));
+  class_>(
+      "DiscretizedCollisionAndJointBound",
       "Create a discretized path validation checking both collision and joint "
-      "bounds.");
+      "bounds.",
+      init(
+          (arg("robot"), arg("stepSize"))));
+  class_>(
+      "Progressive", "Create a progressive continuous path validation.",
+      init(
+          (arg("robot"), arg("tolerance"))));
+  class_>(
+      "Dichotomy", "Create a dichotomy-based continuous path validation.",
+      init(
+          (arg("robot"), arg("tolerance"))));
 }
+
 }  // namespace core
 }  // namespace pyhpp
diff --git a/src/pyhpp/core/problem.cc b/src/pyhpp/core/problem.cc
index 474b4aaa..494175b1 100644
--- a/src/pyhpp/core/problem.cc
+++ b/src/pyhpp/core/problem.cc
@@ -51,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace {
@@ -171,6 +172,12 @@ PathValidationPtr_t Problem::pathValidation() const {
   return obj->pathValidation();
 }
 
+PyWPathValidationPtr_t Problem::pyPathValidation() const {
+  if (pathValidation_ && pathValidation_->obj == obj->pathValidation())
+    return pathValidation_;
+  return std::make_shared(obj->pathValidation());
+}
+
 PathProjectorPtr_t Problem::pathProjector() const {
   return obj->pathProjector();
 }
@@ -193,6 +200,12 @@ void Problem::configValidation(const ConfigValidationsPtr_t& cv) {
 
 void Problem::pathValidation(const PathValidationPtr_t& pv) {
   obj->pathValidation(pv);
+  pathValidation_.reset();
+}
+
+void Problem::pyPathValidation(const PyWPathValidationPtr_t& pv) {
+  obj->pathValidation(pv->obj);
+  pathValidation_ = pv;
 }
 
 void Problem::pathProjector(const PathProjectorPtr_t& pp) {
@@ -586,8 +599,8 @@ typedef void (Problem::*SetSteeringMethod)(const PyWSteeringMethodPtr_t&);
 typedef const ConfigValidationsPtr_t& (Problem::*GetConfigValidation)() const;
 typedef void (Problem::*SetConfigValidation)(const ConfigValidationsPtr_t&);
 
-typedef PathValidationPtr_t (Problem::*GetPathValidation)() const;
-typedef void (Problem::*SetPathValidation)(const PathValidationPtr_t&);
+typedef PyWPathValidationPtr_t (Problem::*GetPathValidation)() const;
+typedef void (Problem::*SetPathValidation)(const PyWPathValidationPtr_t&);
 
 typedef PathProjectorPtr_t (Problem::*GetPathProjector)() const;
 typedef void (Problem::*SetPathProjector)(const PathProjectorPtr_t&);
@@ -692,10 +705,10 @@ void exposeProblem() {
            DocClassMethod(clearConfigValidations))
 
       .def("pathValidation",
-           static_cast(&Problem::pathValidation),
+           static_cast(&Problem::pyPathValidation),
            DOC_P_PV_GET)
       .def("pathValidation",
-           static_cast(&Problem::pathValidation),
+           static_cast(&Problem::pyPathValidation),
            (arg("pathValidation")), DOC_P_PV_SET)
 
       .def("pathProjector",
diff --git a/src/pyhpp/manipulation/graph.cc b/src/pyhpp/manipulation/graph.cc
index 37ad8cfe..a123c68c 100644
--- a/src/pyhpp/manipulation/graph.cc
+++ b/src/pyhpp/manipulation/graph.cc
@@ -44,6 +44,7 @@
 #include 
 
 #include "hpp/manipulation/constraint-set.hh"
+#include "pyhpp/core/path-validation.hh"
 #include "pyhpp/core/problem.hh"
 
 // DocNamespace(hpp::manipulation::graph)
@@ -952,6 +953,10 @@ PathValidationPtr_t PyWEdge::pathValidation() const {
   return obj->pathValidation();
 }
 
+pyhpp::core::PyWPathValidationPtr_t PyWEdge::pyPathValidation() const {
+  return std::make_shared(obj->pathValidation());
+}
+
 // =============================================================================
 // Subgraph management
 // =============================================================================
@@ -1305,7 +1310,7 @@ void exposeGraph() {
       .def("nbWaypoints", &PyWEdge::nbWaypoints, DocClassMethod(nbWaypoints))
       .def("waypoint", &PyWEdge::waypoint, DocClassMethod(waypoint))
       .def("nbWaypoints", &PyWEdge::nbWaypoints, DocClassMethod(nbWaypoints))
-      .def("pathValidation", &PyWEdge::pathValidation,
+      .def("pathValidation", &PyWEdge::pyPathValidation,
            DocClassMethod(pathValidation));
 
   // DocClass(Graph)
diff --git a/src/pyhpp/manipulation/graph.hh b/src/pyhpp/manipulation/graph.hh
index 47779f9c..f7d62219 100644
--- a/src/pyhpp/manipulation/graph.hh
+++ b/src/pyhpp/manipulation/graph.hh
@@ -32,6 +32,7 @@
 
 #include 
 #include 
+#include 
 #include 
 
 namespace pyhpp {
@@ -76,6 +77,7 @@ struct PyWEdge {
   std::size_t weight() const;
   PyWEdge waypoint(int index) const;
   PathValidationPtr_t pathValidation() const;
+  pyhpp::core::PyWPathValidationPtr_t pyPathValidation() const;
 };
 typedef std::shared_ptr PyWEdgePtr_t;
 
diff --git a/src/pyhpp/manipulation/path-planner.cc b/src/pyhpp/manipulation/path-planner.cc
index d4c2d522..842b8b30 100644
--- a/src/pyhpp/manipulation/path-planner.cc
+++ b/src/pyhpp/manipulation/path-planner.cc
@@ -56,6 +56,21 @@ const char* DOC_CHECKFEASIBILITYONLY =
     "If enabled, only add one solution to the roadmap. "
     "Otherwise add all solutions.";
 
+template 
+void plannerStartSolve(Planner& planner) {
+  planner.obj->startSolve();
+}
+
+template 
+void plannerTryConnectInitAndGoals(Planner& planner) {
+  planner.obj->tryConnectInitAndGoals();
+}
+
+template 
+void plannerOneStep(Planner& planner) {
+  planner.obj->oneStep();
+}
+
 }  // namespace
 
 namespace pyhpp {
@@ -322,6 +337,10 @@ void exposePathPlanners() {
                         boost::python::bases>(
       "TransitionPlanner", DocClassDoc(),
       boost::python::init())
+      .def("startSolve", &plannerStartSolve,
+           DocClassMethod(startSolve))
+      .def("oneStep", &plannerOneStep,
+           DocClassMethod(oneStep))
       .def("innerPlanner",
            static_cast(
                &TransitionPlanner::innerPlanner),
@@ -360,14 +379,28 @@ void exposePathPlanners() {
       .def("addPathOptimizer", &TransitionPlanner::addPathOptimizer,
            DocClassMethod(addPathOptimizer));
 
+  // DocNamespace(hpp::manipulation)
+  // DocClass(ManipulationPlanner)
   boost::python::class_>(
-      "ManipulationPlanner",
-      boost::python::init());
+      "ManipulationPlanner", DocClassDoc(),
+      boost::python::init())
+      .def("oneStep", &plannerOneStep,
+           DocClassMethod(oneStep));
 
+  // DocNamespace(hpp::manipulation::pathPlanner)
+  // DocClass(StatesPathFinder)
   boost::python::class_>(
-      "StatesPathFinder", boost::python::init());
+      "StatesPathFinder", DocClassDoc(),
+      boost::python::init())
+      .def("startSolve", &plannerStartSolve,
+           DocClassMethod(startSolve))
+      .def("tryConnectInitAndGoals",
+           &plannerTryConnectInitAndGoals,
+           DocClassMethod(tryConnectInitAndGoals))
+      .def("oneStep", &plannerOneStep,
+           DocClassMethod(oneStep));
 
   // DocClass(EndEffectorTrajectory)
   boost::python::class_())
       .def(boost::python::init())
+      .def("startSolve", &plannerStartSolve,
+           DocClassMethod(startSolve))
+      .def("tryConnectInitAndGoals",
+           &plannerTryConnectInitAndGoals,
+           DocClassMethod(tryConnectInitAndGoals))
+      .def("oneStep", &plannerOneStep,
+           DocClassMethod(oneStep))
       .def("nRandomConfig",
            static_cast(
                &EndEffectorTrajectory::nRandomConfig),
diff --git a/src/pyhpp/manipulation/problem.cc b/src/pyhpp/manipulation/problem.cc
index 57856cff..d818f291 100644
--- a/src/pyhpp/manipulation/problem.cc
+++ b/src/pyhpp/manipulation/problem.cc
@@ -33,11 +33,14 @@
 #include <../src/pyhpp/manipulation/steering-method.hh>
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 // DocNamespace(hpp::manipulation)
@@ -54,6 +57,39 @@ using namespace boost::python;
 namespace pyhpp {
 namespace manipulation {
 
+namespace {
+
+struct GraphPathValidation : pyhpp::core::PathValidation {
+  GraphPathValidation(const pyhpp::core::PyWPathValidationPtr_t& pathValidation)
+      : GraphPathValidation(check(pathValidation), 0) {}
+
+ private:
+  GraphPathValidation(const pyhpp::core::PyWPathValidationPtr_t& pathValidation,
+                      int)
+      : pyhpp::core::PathValidation(
+            hpp::manipulation::GraphPathValidation::create(pathValidation->obj),
+            graphFactory(pathValidation->factory), pathValidation->tolerance) {}
+
+  static pyhpp::core::PyWPathValidationPtr_t check(
+      const pyhpp::core::PyWPathValidationPtr_t& pathValidation) {
+    if (!pathValidation || !pathValidation->factory)
+      throw std::invalid_argument("Path validation has no factory.");
+    return pathValidation;
+  }
+
+  static hpp::core::PathValidationBuilder_t graphFactory(
+      const hpp::core::PathValidationBuilder_t& innerFactory) {
+    return [innerFactory](const hpp::core::DevicePtr_t& robot,
+                          const hpp::core::value_type& tolerance)
+               -> hpp::core::PathValidationPtr_t {
+      return hpp::manipulation::GraphPathValidation::create(
+          innerFactory(robot, tolerance));
+    };
+  }
+};
+
+}  // namespace
+
 Problem::Problem(const PyWDevicePtr_t& robot)
     : pyhpp::core::Problem(
           hpp::manipulation::Problem::create(robot->asManipulationDevice())) {}
@@ -110,6 +146,38 @@ pyhpp::core::PyWSteeringMethodPtr_t Problem::steeringMethod() const {
   return std::shared_ptr(sm);
 }
 
+pyhpp::core::PyWPathValidationPtr_t Problem::pyPathValidation() const {
+  if (pathValidation_ && pathValidation_->obj == obj->pathValidation())
+    return pathValidation_;
+  return std::make_shared(obj->pathValidation());
+}
+
+void Problem::pyPathValidation(
+    const pyhpp::core::PyWPathValidationPtr_t& pathValidation) {
+  hpp::manipulation::ProblemPtr_t problem = asManipulationProblem();
+  auto obstacleUser = HPP_DYNAMIC_PTR_CAST(hpp::core::ObstacleUserInterface,
+                                           pathValidation->obj);
+  if (obstacleUser)
+    for (const auto& obstacle : problem->collisionObstacles())
+      obstacleUser->addObstacle(obstacle);
+
+  problem->pathValidation(pathValidation->obj);
+  pathValidation_ = pathValidation;
+}
+
+pyhpp::core::PyWPathValidationPtr_t Problem::pyPathValidationFactory() const {
+  return std::make_shared(
+      asManipulationProblem()->pathValidationFactory());
+}
+
+void Problem::pyPathValidationFactory(
+    const pyhpp::core::PyWPathValidationPtr_t& pathValidation) {
+  if (!pathValidation || !pathValidation->factory)
+    throw std::invalid_argument("Path validation has no factory.");
+  asManipulationProblem()->setPathValidationFactory(pathValidation->factory,
+                                                    pathValidation->tolerance);
+}
+
 // PathValidationPtr_t Problem::pathValidation() const {
 //     return obj->pathValidation();
 // }
@@ -145,6 +213,10 @@ pyhpp::core::PyWSteeringMethodPtr_t Problem::steeringMethod() const {
 // }
 
 void exposeProblem() {
+  class_>(
+      "GraphPathValidation", init(
+                                 (arg("pathValidation"))));
+
   // DocClass(Problem)
   class_>("Problem", DocClassDoc(),
                                                init())
@@ -173,6 +245,26 @@ void exposeProblem() {
            "Set the problem steering method directly. Unlike steeringMethod, "
            "this does not wrap the given steering method in a manipulation "
            "graph steering method.")
+      .def(
+          "pathValidation",
+          static_cast(
+              &Problem::pyPathValidation),
+          "Get the path validation object.")
+      .def("pathValidation",
+           static_cast(
+               &Problem::pyPathValidation),
+           (arg("pathValidation")), "Set the path validation object.")
+      .def(
+          "pathValidationFactory",
+          static_cast(
+              &Problem::pyPathValidationFactory),
+          "Create a path validation using the edge-validation factory.")
+      .def("pathValidationFactory",
+           static_cast(
+               &Problem::pyPathValidationFactory),
+           (arg("pathValidation")), "Set the edge-validation factory.")
       // .PYHPP_DEFINE_GETTER_SETTER_CONST_REF(Problem, pathValidation,
       // PathValidationPtr_t) .PYHPP_DEFINE_METHOD(Problem,
       // manipulationSteeringMethod) .PYHPP_DEFINE_METHOD(Problem,
diff --git a/src/pyhpp/manipulation/problem.hh b/src/pyhpp/manipulation/problem.hh
index d2fbf0bc..db2775e1 100644
--- a/src/pyhpp/manipulation/problem.hh
+++ b/src/pyhpp/manipulation/problem.hh
@@ -62,6 +62,12 @@ struct Problem : public pyhpp::core::Problem {
   pyhpp::core::PyWSteeringMethodPtr_t steeringMethod() const;
   void fullSteeringMethod(
       const pyhpp::core::PyWSteeringMethodPtr_t& steeringMethod);
+  pyhpp::core::PyWPathValidationPtr_t pyPathValidation() const;
+  void pyPathValidation(
+      const pyhpp::core::PyWPathValidationPtr_t& pathValidation);
+  pyhpp::core::PyWPathValidationPtr_t pyPathValidationFactory() const;
+  void pyPathValidationFactory(
+      const pyhpp::core::PyWPathValidationPtr_t& pathValidation);
   // PathValidationPtr_t pathValidation() const;
   // void pathValidation (const PathValidationPtr_t &pathValidation);
   // SteeringMethodPtr_t manipulationSteeringMethod() const;
diff --git a/tests/integration/construction-set-m-rrt.py b/tests/integration/construction-set-m-rrt.py
index e3c64346..91629a60 100644
--- a/tests/integration/construction-set-m-rrt.py
+++ b/tests/integration/construction-set-m-rrt.py
@@ -408,6 +408,7 @@ def forbidExcept(g: str, h: T.List[str]) -> T.List[Rule]:
 
     problem.steeringMethod(Straight(problem))
     problem.pathValidation(Progressive(robot, 0.02))
+    problem.pathValidationFactory(Progressive(robot, 0.02))
     problem.pathProjector(
         ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.05)
     )
@@ -447,6 +448,7 @@ def forbidExcept(g: str, h: T.List[str]) -> T.List[Rule]:
 
     problem.steeringMethod(Straight(problem))
     problem.pathValidation(Progressive(robot, 0.02))
+    problem.pathValidationFactory(Progressive(robot, 0.02))
     problem.pathProjector(
         ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.05)
     )
diff --git a/tests/integration/ur3-spheres-spf.py b/tests/integration/ur3-spheres-spf.py
index d49818d9..20b7619d 100644
--- a/tests/integration/ur3-spheres-spf.py
+++ b/tests/integration/ur3-spheres-spf.py
@@ -281,6 +281,7 @@
 
 problem.steeringMethod(Straight(problem))
 problem.pathValidation(Dichotomy(robot, 0))
+problem.pathValidationFactory(Dichotomy(robot, 0))
 
 # need to set path projector due to implicit constraints added above
 problem.pathProjector(
diff --git a/tests/integration/ur3-spheres.py b/tests/integration/ur3-spheres.py
index 0b7c3bdf..5db12b04 100644
--- a/tests/integration/ur3-spheres.py
+++ b/tests/integration/ur3-spheres.py
@@ -260,6 +260,7 @@
 
 problem.steeringMethod(Straight(problem))
 problem.pathValidation(Dichotomy(robot, 0))
+problem.pathValidationFactory(Dichotomy(robot, 0))
 problem.pathProjector(
     ProgressiveProjector(problem.distance(), problem.steeringMethod(), 0.01)
 )
diff --git a/tests/unit/test_doxygen_xml_parser.py b/tests/unit/test_doxygen_xml_parser.py
new file mode 100644
index 00000000..91edced4
--- /dev/null
+++ b/tests/unit/test_doxygen_xml_parser.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2026 CNRS
+# Author: Paul Sardin
+#
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from doc.doxygen_xml_parser import ClassDoc
+
+
+DOXYGEN_XML = """\
+
+  
+    hpp::core::Planner
+    
+      Class brief.
+    
+    
+      Class details.
+    
+    
+      
+        startSolve
+        
+          Start solving.
+        
+        
+          Use the child planner.
+        
+      
+    
+  
+
+"""
+
+
+class TestDoxygenXmlParser(unittest.TestCase):
+    def setUp(self):
+        self.temporary_directory = tempfile.TemporaryDirectory()
+        filename = Path(self.temporary_directory.name) / "planner.xml"
+        filename.write_text(DOXYGEN_XML)
+        self.class_doc = ClassDoc(filename)
+
+    def tearDown(self):
+        self.temporary_directory.cleanup()
+
+    def test_nested_class_description(self):
+        self.assertEqual(
+            self.class_doc.getClassDoc(), ("Class brief.", "Class details.")
+        )
+
+    def test_nested_method_description(self):
+        self.assertEqual(
+            self.class_doc.getClassMethodDoc("startSolve"),
+            ("Start solving.", "Use the child planner.", ["self"]),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()