Repository: simon-stahlberg/mimir (language: python)

Issue:
Feat/add fmt
Hi guys,

this PR would add:

-  [fmt](https://github.com/fmtlib/fmt) to the project as a dependency which makes formatting and outputting to the console a lot simpler and intuitive (Python-like string formatting).
-  checks for existing installations of the dependencies within the dependencies folder to avoid redownloading and rebuilding them; as well for the pip installation of pymimir which now copies existing installations in the dependencies over to the temp dir (makes development with successive pip installations faster, irrelevant for PyPI packaging steps)
- Ninja as the build generator in the python packaging step, as it is simply much faster than the platform defaults (e.g. auto-parallelism).
- Workflows (Unit/Integration) are also enabled for all branches and PRs in this branch. I don't quite see a reason not to, you could set PRs to require permission to run the workflows if you want. Easy to undo if you don't like it.

Let me know what you think! :)


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 42e250fdc..3bf358914 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@
 .vscode/settings.json
 .vscode/tasks.json
 
+.idea
+cmake-build-*
+
 # Python build artefacts
 __pycache__
 MANIFEST.in
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8d2c36ccc..ca74de367 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -49,6 +49,9 @@ else()
         set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build, options are: Debug Release RelWithDebInfo MinSizeRel." FORCE)
     endif()
     # TODO: Add -Wextra and fix all warnings
+    if(COMPILER_ID MATCHES "GNU")
+        set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto=auto")
+    endif ()
     set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC")
     set(CMAKE_CXX_FLAGS_RELEASE "-O3 -Wall -DNDEBUG")
     set(CMAKE_CXX_FLAGS_PROFILING "-O3 -Wall -pg")
@@ -107,6 +110,11 @@ if(Nauty_FOUND)
   message(STATUS "Found Nauty: ${NAUTY_LIBRARY} ${NAUTY_INCLUDE_DIR}")
 endif()
 
+find_package(fmt REQUIRED)
+if(fmt_FOUND)
+  include_directories(${fmt_INCLUDE_DIRS})
+  message(STATUS "Found fmt: ${fmt_DIR} (found version ${fmt_VERSION})")
+endif()
 
 ##############################################################
 # Add library and executable targets
diff --git a/dependencies/CMakeLists.txt b/dependencies/CMakeLists.txt
index 7191857e6..4b47c5819 100644
--- a/dependencies/CMakeLists.txt
+++ b/dependencies/CMakeLists.txt
@@ -15,6 +15,10 @@ endif()
 # Pass the build type to subdirectories
 set(BUILD_TYPE ${CMAKE_BUILD_TYPE})
 
+if(COMPILER_ID MATCHES "GNU")
+    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto=auto")
+endif ()
+
 
 ##############################################################
 # Add subprojects
@@ -22,6 +26,7 @@ set(BUILD_TYPE ${CMAKE_BUILD_TYPE})
 
 add_subdirectory(benchmark)
 add_subdirectory(boost)
+add_subdirectory(fmt)
 add_subdirectory(googletest)
 add_subdirectory(loki)
 add_subdirectory(nauty)
diff --git a/dependencies/fmt/CMakeLists.txt b/dependencies/fmt/CMakeLists.txt
new file mode 100644
index 000000000..10b9ee9ca
--- /dev/null
+++ b/dependencies/fmt/CMakeLists.txt
@@ -0,0 +1,31 @@
+cmake_minimum_required(VERSION 3.21)
+project(InstallFMT)
+
+include(ExternalProject)
+
+
+message(STATUS "Dependency \"fmt\"...")
+
+# Attempt to find the installed googletest library
+find_package(fmt QUIET PATHS ${CMAKE_INSTALL_PREFIX} NO_DEFAULT_PATH)
+
+if (NOT fmt_FOUND)
+    list(APPEND CMAKE_ARGS
+            -DCMAKE_INSTALL_PREFIX:PATH=${CMAKE_INSTALL_PREFIX}
+    )
+    message(STATUS "Preparing external project \"fmt\" with args:")
+    foreach (CMAKE_ARG ${CMAKE_ARGS})
+        message(STATUS "-- ${CMAKE_ARG}")
+    endforeach ()
+
+    ExternalProject_Add(
+            fmt
+            GIT_REPOSITORY https://github.com/fmtlib/fmt
+            GIT_TAG 11.0.2
+            PREFIX ${CMAKE_BINARY_DIR}/fmt
+            CMAKE_ARGS ${CMAKE_ARGS}
+    )
+else ()
+    message(STATUS "googletest is already installed.")
+endif ()
+
diff --git a/include/mimir/formalism/factories.hpp b/include/mimir/formalism/factories.hpp
index 81aaa0330..586a128e0 100644
--- a/include/mimir/formalism/factories.hpp
+++ b/include/mimir/formalism/factories.hpp
@@ -285,6 +285,12 @@ class PDDLFactories
     template<PredicateCategory P, std::ranges::forward_range Iterable>
     GroundAtomList<P> get_ground_atoms_from_indices(const Iterable& atom_ids) const;
 
+    template<PredicateCategory P>
+    void get_ground_atoms(GroundAtomList<P>& out_ground_atoms) const;
+
+    template<PredicateCategory P>
+    auto get_ground_atoms() const;
+
     // Object
     Object get_object(size_t object_id) const;
 
@@ -343,6 +349,23 @@ GroundAtomList<P> PDDLFactories::get_ground_atoms_from_indices(const Iterable& a
     return result;
 }
 
+template<PredicateCategory P>
+void PDDLFactories::get_ground_atoms(GroundAtomList<P>& out_ground_atoms) const
+{
+    out_ground_atoms.clear();
+    for (const auto& atom : get_factory<GroundAtomFactory<P>>())
+    {
+        out_ground_atoms.push_back(atom);
+    }
+}
+
+template<PredicateCategory P>
+auto PDDLFactories::get_ground_atoms() const
+{
+    const auto& factory = get_factory<GroundAtomFactory<P>>();
+    return std::ranges::subrange(factory.begin(), factory.end());
+}
+
 template<std::ranges::forward_range Iterable>
 void PDDLFactories::get_objects_from_indices(const Iterable& object_ids, ObjectList& out_objects) const
 {
diff --git a/include/mimir/utils/utils.hpp b/include/mimir/utils/utils.hpp
new file mode 100644
index 000000000..09c2d5d22
--- /dev/null
+++ b/include/mimir/utils/utils.hpp
@@ -0,0 +1,82 @@
+
+#ifndef MIMIR_UTILS_UTILS_HPP
+#define MIMIR_UTILS_UTILS_HPP
+
+#include <memory>
+#include <optional>
+#include <type_traits>
+
+#ifndef FWD
+#define FWD(x) std::forward< decltype(x) >(x)
+#endif  // FWD
+
+namespace mimir
+{
+
+/// is_specialization checks whether T is a specialized template class of 'Template'
+/// This has the limitation of
+/// usage:
+///     constexpr bool is_vector = is_specialization< std::vector< int >, std::vector>;
+///
+/// Note that this trait has 2 limitations:
+///  1) Does not work with non-type parameters.
+///     (i.e. templates such as std::array will not be compatible with this type trait)
+///  2) Generally, template aliases do not get captured as the underlying template but as the
+///     alias template. As such the following scenarios will return for an alias
+///     template <typename T> using uptr = std::unique_ptr< T >:
+///          specialization<uptr<int>, std::unique_ptr>;              -> true
+///          specialization<uptr<int>, uptr> << std::endl;            -> false
+///          specialization<std::unique_ptr<int>, std::unique_ptr>;   -> true
+///          specialization<std::unique_ptr<int>, uptr>;              -> false
+;
+template<class T, template<class...> class Template>
+struct is_specialization : std::false_type
+{
+};
+
+template<template<class...> class Template, class... Args>
+struct is_specialization<Template<Args...>, Template> : std::true_type
+{
+};
+
+template<class T, template<class...> class Template>
+constexpr bool is_specialization_v = is_specialization<T, Template>::value;
+
+template<typename T>
+using raw = std::remove_cvref<T>;
+
+template<typename T>
+using raw_t = typename raw<T>::type;
+
+template<typename T>
+decltype(auto) deref(T&& t)
+{
+    return FWD(t);
+}
+
+template<typename T>
+    requires std::is_pointer_v<raw_t<T>>                               //
+             or is_specialization_v<raw_t<T>, std::reference_wrapper>  //
+             or is_specialization_v<raw_t<T>, std::optional>           //
+             or is_specialization_v<raw_t<T>, std::shared_ptr>         //
+             or is_specialization_v<raw_t<T>, std::unique_ptr>         //
+             or std::input_or_output_iterator<raw_t<T>>                //
+decltype(auto) deref(T&& t)
+{
+    if constexpr (std::is_pointer_v<raw_t<T>>                 //
+                  or std::input_or_output_iterator<raw_t<T>>  //
+                  or is_specialization_v<raw_t<T>, std::optional>)
+    {
+        return *FWD(t);
+    }
+    else
+    {
+        return deref(FWD(t).get());
+    }
+}
+
+template<typename T>
+using dereffed_t = decltype(deref(std::declval<T>()));
+}
+
+#endif  // MIMIR_UTILS_UTILS_HPP
diff --git a/python/src/CMakeLists.txt b/python/src/CMakeLists.txt
index 102ab90a4..6a5af3db3 100644
--- a/python/src/CMakeLists.txt
+++ b/python/src/CMakeLists.txt
@@ -2,15 +2,31 @@ find_package(pybind11 CONFIG REQUIRED PATHS ${CMAKE_PREFIX_PATH} NO_DEFAULT_PATH
 # Set result variables
 find_package(pybind11)
 
+add_library(_pymimir_objects
+        OBJECT
+        pymimir/bindings.cpp
+)
+target_include_directories(_pymimir_objects
+        PUBLIC
+        ${CMAKE_CURRENT_SOURCE_DIR}/pymimir
+)
+
+target_link_libraries(_pymimir_objects
+        PUBLIC
+        mimir::core
+        pybind11::module
+)
+
 pybind11_add_module(_pymimir MODULE)
 target_sources(_pymimir
-    PRIVATE
+        PRIVATE
+        $<TARGET_OBJECTS:_pymimir_objects>
         pymimir/main.cpp
-        pymimir/bindings.cpp)
+)
 target_link_libraries(_pymimir
-    PRIVATE
-        pybind11::module
-        mimir::core)
+        PRIVATE
+        _pymimir_objects
+)
 target_compile_definitions(_pymimir PUBLIC MIMIR_VERSION_INFO="${MIMIR_VERSION_INFO}")
 
 target_compile_definitions(_pymimir PRIVATE PYBIND11_DETAILED_ERROR_MESSAGES)
diff --git a/python/src/pymimir/bindings.cpp b/python/src/pymimir/bindings.cpp
index 2ab64bbc4..397b421e3 100644
--- a/python/src/pymimir/bindings.cpp
+++ b/python/src/pymimir/bindings.cpp
@@ -1,26 +1,7 @@
-#include "mimir/datasets/state_space.hpp"
-#include "mimir/formalism/atom.hpp"
-#include "mimir/formalism/declarations.hpp"
-#include "mimir/formalism/factories.hpp"
-#include "mimir/formalism/ground_atom.hpp"
-#include "mimir/formalism/object.hpp"
-#include "mimir/formalism/predicate.hpp"
-#include "mimir/formalism/predicate_category.hpp"
-#include "mimir/formalism/requirements.hpp"
-#include "mimir/formalism/term.hpp"
-#include "mimir/formalism/variable.hpp"
-
-#include <pybind11/detail/common.h>
-#include <pybind11/pybind11.h>
-#include <pybind11/stl.h>  // Necessary for automatic conversion of e.g. std::vectors
-#include <pybind11/stl_bind.h>
-
-#define STRINGIFY(x) #x
-#define MACRO_STRINGIFY(x) STRINGIFY(x)
-
-#include "mimir/mimir.hpp"
+#include "init_declarations.hpp"
 
 using namespace mimir;
+
 namespace py = pybind11;
 
 /**
@@ -92,6 +73,14 @@ struct CastVisitor
         return py::cast(element);
     }
 };
+struct ReprVisitor
+{
+    template<typename T>
+    std::string operator()(const T& element) const
+    {
+        return element.str();
+    }
+};
 
 /**
  * Opaque types
@@ -398,7 +387,8 @@ void init_pymimir(py::module_& m)
         .def("get_index", &ObjectImpl::get_index)
         .def("get_name", &ObjectImpl::get_name, py::return_value_policy::reference_internal);
     static_assert(!py::detail::vector_needs_copy<ObjectList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<ObjectList>(m, "ObjectList");
+    auto list_class = py::bind_vector<ObjectList>(m, "ObjectList");
+    def_opaque_vector_repr<ObjectList>(list_class, "ObjectList");
 
     py::class_<VariableImpl>(m, "Variable")  //
         .def("__str__", &VariableImpl::str)
@@ -406,7 +396,8 @@ void init_pymimir(py::module_& m)
         .def("get_index", &VariableImpl::get_index)
         .def("get_name", &VariableImpl::get_name, py::return_value_policy::reference_internal);
     static_assert(!py::detail::vector_needs_copy<VariableList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<VariableList>(m, "VariableList");
+    list_class = py::bind_vector<VariableList>(m, "VariableList");
+    def_opaque_vector_repr<VariableList>(list_class, "VariableList");
 
     py::class_<TermObjectImpl>(m, "TermObject")  //
         .def("__str__", &TermObjectImpl::str)
@@ -421,219 +412,131 @@ void init_pymimir(py::module_& m)
         .def("get_variable", &TermVariableImpl::get_variable, py::return_value_policy::reference_internal);
 
     py::class_<TermVariant>(m, "Term")  //
-        .def(
-            "get",
-            [](const TermVariant& arg) -> py::object { return std::visit(CastVisitor(), *arg.term); },
-            py::keep_alive<0, 1>());
+        .def("get", [](const TermVariant& arg) -> py::object { return std::visit(CastVisitor(), *arg.term); }, py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<TermVariantList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<TermVariantList>(m, "TermVariantList");
-
-    py::class_<PredicateImpl<Static>>(m, "StaticPredicate")  //
-        .def("__str__", &PredicateImpl<Static>::str)
-        .def("__repr__", &PredicateImpl<Static>::str)
-        .def("get_index", &PredicateImpl<Static>::get_index)
-        .def("get_name", &PredicateImpl<Static>::get_name, py::return_value_policy::reference_internal)
-        .def(
-            "get_parameters",
-            [](const PredicateImpl<Static>& self) { return VariableList(self.get_parameters()); },
-            py::keep_alive<0, 1>())
-        .def("get_arity", &PredicateImpl<Static>::get_arity);
-    static_assert(!py::detail::vector_needs_copy<PredicateList<Static>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<PredicateList<Static>>(m, "StaticPredicateList");
-    py::bind_map<ToPredicateMap<std::string, Static>>(m, "StringToStaticPredicateMap");
-
-    py::class_<PredicateImpl<Fluent>>(m, "FluentPredicate")  //
-        .def("__str__", &PredicateImpl<Fluent>::str)
-        .def("__repr__", &PredicateImpl<Fluent>::str)
-        .def("get_index", &PredicateImpl<Fluent>::get_index)
-        .def("get_name", &PredicateImpl<Fluent>::get_name, py::return_value_policy::reference_internal)
-        .def(
-            "get_parameters",
-            [](const PredicateImpl<Fluent>& self) { return VariableList(self.get_parameters()); },
-            py::keep_alive<0, 1>())
-        .def("get_arity", &PredicateImpl<Fluent>::get_arity);
-    static_assert(!py::detail::vector_needs_copy<PredicateList<Fluent>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<PredicateList<Fluent>>(m, "FluentPredicateList");
-    py::bind_map<ToPredicateMap<std::string, Fluent>>(m, "StringToFluentPredicateMap");
+    list_class = py::bind_vector<TermVariantList>(m, "TermVariantList");
+    def_opaque_vector_repr<TermVariantList>(list_class, "TermVariantList", [](const TermVariant& elem) { return std::visit(ReprVisitor {}, *elem.term); });
 
-    py::class_<PredicateImpl<Derived>>(m, "DerivedPredicate")  //
-        .def("__str__", &PredicateImpl<Derived>::str)
-        .def("__repr__", &PredicateImpl<Derived>::str)
-        .def("get_index", &PredicateImpl<Derived>::get_index)
-        .def("get_name", &PredicateImpl<Derived>::get_name, py::return_value_policy::reference_internal)
-        .def(
-            "get_parameters",
-            [](const PredicateImpl<Derived>& self) { return VariableList(self.get_parameters()); },
-            py::keep_alive<0, 1>())
-        .def("get_arity", &PredicateImpl<Derived>::get_arity);
-    static_assert(!py::detail::vector_needs_copy<PredicateList<Derived>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<PredicateList<Derived>>(m, "DerivedPredicateList");
-    py::bind_map<ToPredicateMap<std::string, Derived>>(m, "StringToDerivedPredicateMap");
-
-    py::class_<AtomImpl<Static>>(m, "StaticAtom")  //
-        .def("__str__", &AtomImpl<Static>::str)
-        .def("__repr__", &AtomImpl<Static>::str)
-        .def("get_index", &AtomImpl<Static>::get_index)
-        .def("get_predicate", &AtomImpl<Static>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_terms",
-            [](const AtomImpl<Static>& atom) { return to_term_variant_list(atom.get_terms()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<AtomList<Static>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<AtomList<Static>>(m, "StaticAtomList");
-
-    py::class_<AtomImpl<Fluent>>(m, "FluentAtom")  //
-        .def("__str__", &AtomImpl<Fluent>::str)
-        .def("__repr__", &AtomImpl<Fluent>::str)
-        .def("get_index", &AtomImpl<Fluent>::get_index)
-        .def("get_predicate", &AtomImpl<Fluent>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_terms",
-            [](const AtomImpl<Fluent>& atom) { return to_term_variant_list(atom.get_terms()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<AtomList<Fluent>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<AtomList<Fluent>>(m, "FluentAtomList");
+    auto bind_predicate = [&]<typename Tag>(const std::string& class_name, Tag)
+    {
+        py::class_<PredicateImpl<Tag>>(m, class_name.c_str())
+            .def("__str__", &PredicateImpl<Tag>::str)
+            .def("__repr__", &PredicateImpl<Tag>::str)
+            .def("get_index", &PredicateImpl<Tag>::get_index)
+            .def("get_name", &PredicateImpl<Tag>::get_name, py::return_value_policy::reference_internal)
+            .def(
+                "get_parameters",
+                [](const PredicateImpl<Tag>& self) { return VariableList(self.get_parameters()); },
+                py::keep_alive<0, 1>())
+            .def("get_arity", &PredicateImpl<Tag>::get_arity);
+
+        static_assert(!py::detail::vector_needs_copy<PredicateList<Tag>>::value);
+        auto predicate_list = py::bind_vector<PredicateList<Tag>>(m, class_name + "List");
+        def_opaque_vector_repr<PredicateList<Tag>>(predicate_list, class_name + "List");
+        py::bind_map<ToPredicateMap<std::string, Tag>>(m, "StringTo" + class_name + "Map");
+    };
+    bind_predicate("StaticPredicate", Static {});
+    bind_predicate("FluentPredicate", Fluent {});
+    bind_predicate("DerivedPredicate", Derived {});
 
-    py::class_<AtomImpl<Derived>>(m, "DerivedAtom")  //
-        .def("__str__", &AtomImpl<Derived>::str)
-        .def("__repr__", &AtomImpl<Derived>::str)
-        .def("get_index", &AtomImpl<Derived>::get_index)
-        .def("get_predicate", &AtomImpl<Derived>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_terms",
-            [](const AtomImpl<Derived>& atom) { return to_term_variant_list(atom.get_terms()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<AtomList<Derived>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<AtomList<Derived>>(m, "DerivedAtomList");
+    auto bind_atom = [&]<typename Tag>(const std::string& class_name, Tag)
+    {
+        py::class_<AtomImpl<Tag>>(m, class_name.c_str())
+            .def("__str__", &AtomImpl<Tag>::str)
+            .def("__repr__", &AtomImpl<Tag>::str)
+            .def("get_index", &AtomImpl<Tag>::get_index)
+            .def("get_predicate", &AtomImpl<Tag>::get_predicate, py::return_value_policy::reference_internal)
+            .def("get_terms", [](const AtomImpl<Tag>& atom) { return atom.get_terms(); }, py::keep_alive<0, 1>());
+
+        static_assert(!py::detail::vector_needs_copy<AtomList<Tag>>::value);
+        auto list_cls = py::bind_vector<AtomList<Tag>>(m, class_name + "List");
+        def_opaque_vector_repr<AtomList<Tag>>(list_cls, class_name + "List");
+    };
+    bind_atom("StaticAtom", Static {});
+    bind_atom("FluentAtom", Fluent {});
+    bind_atom("DerivedAtom", Derived {});
 
     py::class_<FunctionSkeletonImpl>(m, "FunctionSkeleton")  //
         .def("__str__", &FunctionSkeletonImpl::str)
         .def("__repr__", &FunctionSkeletonImpl::str)
         .def("get_index", &FunctionSkeletonImpl::get_index)
         .def("get_name", &FunctionSkeletonImpl::get_name, py::return_value_policy::reference_internal)
-        .def(
-            "get_parameters",
-            [](const FunctionSkeletonImpl& self) { return VariableList(self.get_parameters()); },
-            py::keep_alive<0, 1>());
+        .def("get_parameters", [](const FunctionSkeletonImpl& self) { return VariableList(self.get_parameters()); }, py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<FunctionSkeletonList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<FunctionSkeletonList>(m, "FunctionSkeletonList");
+    list_class = py::bind_vector<FunctionSkeletonList>(m, "FunctionSkeletonList");
+    def_opaque_vector_repr<FunctionSkeletonList>(list_class, "FunctionSkeletonList");
 
     py::class_<FunctionImpl>(m, "Function")  //
         .def("__str__", &FunctionImpl::str)
         .def("__repr__", &FunctionImpl::str)
         .def("get_index", &FunctionImpl::get_index)
         .def("get_function_skeleton", &FunctionImpl::get_function_skeleton, py::return_value_policy::reference_internal)
-        .def(
-            "get_terms",
-            [](const FunctionImpl& self) { return to_term_variant_list(self.get_terms()); },
-            py::keep_alive<0, 1>());
+        .def("get_terms", [](const FunctionImpl& self) { return to_term_variant_list(self.get_terms()); }, py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<FunctionList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<FunctionList>(m, "FunctionList");
+    list_class = py::bind_vector<FunctionList>(m, "FunctionList");
+    def_opaque_vector_repr<FunctionSkeletonList>(list_class, "FunctionList");
 
     py::class_<GroundFunctionImpl>(m, "GroundFunction")  //
         .def("__str__", &GroundFunctionImpl::str)
         .def("__repr__", &GroundFunctionImpl::str)
         .def("get_index", &GroundFunctionImpl::get_index)
         .def("get_function_skeleton", &GroundFunctionImpl::get_function_skeleton, py::return_value_policy::reference_internal)
-        .def(
-            "get_objects",
-            [](const GroundFunctionImpl& self) { return ObjectList(self.get_objects()); },
-            py::keep_alive<0, 1>());
+        .def("get_objects", [](const GroundFunctionImpl& self) { return ObjectList(self.get_objects()); }, py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<GroundFunctionList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundFunctionList>(m, "GroundFunctionList");
+    list_class = py::bind_vector<GroundFunctionList>(m, "GroundFunctionList");
+    def_opaque_vector_repr<FunctionSkeletonList>(list_class, "GroundFunctionList");
 
-    py::class_<GroundAtomImpl<Static>>(m, "StaticGroundAtom")  //
-        .def("__str__", &GroundAtomImpl<Static>::str)
-        .def("__repr__", &GroundAtomImpl<Static>::str)
-        .def("get_index", &GroundAtomImpl<Static>::get_index)
-        .def("get_arity", &GroundAtomImpl<Static>::get_arity)
-        .def("get_predicate", &GroundAtomImpl<Static>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_objects",
-            [](const GroundAtomImpl<Static>& self) { return ObjectList(self.get_objects()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<GroundAtomList<Static>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundAtomList<Static>>(m, "StaticGroundAtomList");
+    auto bind_ground_atom = [&]<typename Tag>(const std::string& class_name, Tag)
+    {
+        py::class_<GroundAtomImpl<Tag>>(m, class_name.c_str())
+            .def("__str__", &GroundAtomImpl<Tag>::str)
+            .def("__repr__", &GroundAtomImpl<Tag>::str)
+            .def("get_index", &GroundAtomImpl<Tag>::get_index)
+            .def("get_arity", &GroundAtomImpl<Tag>::get_arity)
+            .def("get_predicate", &GroundAtomImpl<Tag>::get_predicate, py::return_value_policy::reference_internal)
+            .def("get_objects", [](const GroundAtomImpl<Tag>& self) { return ObjectList(self.get_objects()); }, py::keep_alive<0, 1>());
+
+        static_assert(!py::detail::vector_needs_copy<GroundAtomList<Tag>>::value);
+        list_class = py::bind_vector<GroundAtomList<Tag>>(m, class_name + "List");
+        def_opaque_vector_repr<GroundAtomList<Tag>>(list_class, class_name + "List");
+    };
+    bind_ground_atom("StaticGroundAtom", Static {});
+    bind_ground_atom("FluentGroundAtom", Fluent {});
+    bind_ground_atom("DerivedGroundAtom", Derived {});
 
-    py::class_<GroundAtomImpl<Fluent>>(m, "FluentGroundAtom")  //
-        .def("__str__", &GroundAtomImpl<Fluent>::str)
-        .def("__repr__", &GroundAtomImpl<Fluent>::str)
-        .def("get_index", &GroundAtomImpl<Fluent>::get_index)
-        .def("get_arity", &GroundAtomImpl<Fluent>::get_arity)
-        .def("get_predicate", &GroundAtomImpl<Fluent>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_objects",
-            [](const GroundAtomImpl<Fluent>& self) { return ObjectList(self.get_objects()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<GroundAtomList<Fluent>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundAtomList<Fluent>>(m, "FluentGroundAtomList");
+    auto bind_ground_literal = [&]<typename Tag>(const std::string& class_name, Tag)
+    {
+        py::class_<GroundLiteralImpl<Tag>>(m, class_name.c_str())
+            .def("__str__", &GroundLiteralImpl<Tag>::str)
+            .def("__repr__", &GroundLiteralImpl<Tag>::str)
+            .def("get_index", &GroundLiteralImpl<Tag>::get_index)
+            .def("get_atom", &GroundLiteralImpl<Tag>::get_atom, py::return_value_policy::reference_internal)
+            .def("is_negated", &GroundLiteralImpl<Tag>::is_negated);
+
+        static_assert(!py::detail::vector_needs_copy<GroundLiteralList<Tag>>::value);
+        auto list = py::bind_vector<GroundLiteralList<Tag>>(m, class_name + "List");
+        def_opaque_vector_repr<GroundLiteralList<Tag>>(list, class_name + "List");
+    };
+    bind_ground_literal("StaticGroundLiteral", Static {});
+    bind_ground_literal("FluentGroundLiteral", Fluent {});
+    bind_ground_literal("DerivedGroundLiteral", Derived {});
 
-    py::class_<GroundAtomImpl<Derived>>(m, "DerivedGroundAtom")  //
-        .def("__str__", &GroundAtomImpl<Derived>::str)
-        .def("__repr__", &GroundAtomImpl<Derived>::str)
-        .def("get_index", &GroundAtomImpl<Derived>::get_index)
-        .def("get_arity", &GroundAtomImpl<Derived>::get_arity)
-        .def("get_predicate", &GroundAtomImpl<Derived>::get_predicate, py::return_value_policy::reference_internal)
-        .def(
-            "get_objects",
-            [](const GroundAtomImpl<Derived>& self) { return ObjectList(self.get_objects()); },
-            py::keep_alive<0, 1>());
-    static_assert(!py::detail::vector_needs_copy<GroundAtomList<Derived>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundAtomList<Derived>>(m, "DerivedGroundAtomList");
-
-    py::class_<GroundLiteralImpl<Static>>(m, "StaticGroundLiteral")  //
-        .def("__str__", &GroundLiteralImpl<Static>::str)
-        .def("__repr__", &GroundLiteralImpl<Static>::str)
-        .def("get_index", &GroundLiteralImpl<Static>::get_index)
-        .def("get_atom", &GroundLiteralImpl<Static>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &GroundLiteralImpl<Static>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<GroundLiteralList<Static>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundLiteralList<Static>>(m, "StaticGroundLiteralList");
-
-    py::class_<GroundLiteralImpl<Fluent>>(m, "FluentGroundLiteral")  //
-        .def("__str__", &GroundLiteralImpl<Fluent>::str)
-        .def("__repr__", &GroundLiteralImpl<Fluent>::str)
-        .def("get_index", &GroundLiteralImpl<Fluent>::get_index)
-        .def("get_atom", &GroundLiteralImpl<Fluent>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &GroundLiteralImpl<Fluent>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<GroundLiteralList<Fluent>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundLiteralList<Fluent>>(m, "FluentGroundLiteralList");
-
-    py::class_<GroundLiteralImpl<Derived>>(m, "DerivedGroundLiteral")  //
-        .def("__str__", &GroundLiteralImpl<Derived>::str)
-        .def("__repr__", &GroundLiteralImpl<Derived>::str)
-        .def("get_index", &GroundLiteralImpl<Derived>::get_index)
-        .def("get_atom", &GroundLiteralImpl<Derived>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &GroundLiteralImpl<Derived>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<GroundLiteralList<Derived>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundLiteralList<Derived>>(m, "DerivedGroundLiteralList");
-
-    py::class_<LiteralImpl<Static>>(m, "StaticLiteral")  //
-        .def("__str__", &LiteralImpl<Static>::str)
-        .def("__repr__", &LiteralImpl<Static>::str)
-        .def("get_index", &LiteralImpl<Static>::get_index)
-        .def("get_atom", &LiteralImpl<Static>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &LiteralImpl<Static>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<LiteralList<Static>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<LiteralList<Static>>(m, "StaticLiteralList");
-
-    py::class_<LiteralImpl<Fluent>>(m, "FluentLiteral")  //
-        .def("__str__", &LiteralImpl<Fluent>::str)
-        .def("__repr__", &LiteralImpl<Fluent>::str)
-        .def("get_index", &LiteralImpl<Fluent>::get_index)
-        .def("get_atom", &LiteralImpl<Fluent>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &LiteralImpl<Fluent>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<LiteralList<Fluent>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<LiteralList<Fluent>>(m, "FluentLiteralList");
-
-    py::class_<LiteralImpl<Derived>>(m, "DerivedLiteral")  //
-        .def("__str__", &LiteralImpl<Derived>::str)
-        .def("__repr__", &LiteralImpl<Derived>::str)
-        .def("get_index", &LiteralImpl<Derived>::get_index)
-        .def("get_atom", &LiteralImpl<Derived>::get_atom, py::return_value_policy::reference_internal)
-        .def("is_negated", &LiteralImpl<Derived>::is_negated);
-    static_assert(!py::detail::vector_needs_copy<LiteralList<Derived>>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<LiteralList<Derived>>(m, "DerivedLiteralList");
+    auto bind_literal = [&]<typename Tag>(const std::string& class_name, Tag)
+    {
+        py::class_<LiteralImpl<Tag>>(m, class_name.c_str())
+            .def("__str__", &LiteralImpl<Tag>::str)
+            .def("__repr__", &LiteralImpl<Tag>::str)
+            .def("get_index", &LiteralImpl<Tag>::get_index)
+            .def("get_atom", &LiteralImpl<Tag>::get_atom, py::return_value_policy::reference_internal)
+            .def("is_negated", &LiteralImpl<Tag>::is_negated);
+
+        static_assert(!py::detail::vector_needs_copy<LiteralList<Tag>>::value);
+        auto list = py::bind_vector<LiteralList<Tag>>(m, class_name + "List");
+        def_opaque_vector_repr<GroundLiteralList<Tag>>(list, "List");
+    };
+    bind_literal("StaticLiteral", Static {});
+    bind_literal("FluentLiteral", Fluent {});
+    bind_literal("DerivedLiteral", Derived {});
 
     py::class_<NumericFluentImpl>(m, "NumericFluent")  //
         .def("__str__", &NumericFluentImpl::str)
@@ -642,7 +545,8 @@ void init_pymimir(py::module_& m)
         .def("get_function", &NumericFluentImpl::get_function, py::return_value_policy::reference_internal)
         .def("get_number", &NumericFluentImpl::get_number);
     static_assert(!py::detail::vector_needs_copy<NumericFluentList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<NumericFluentList>(m, "NumericFluentList");
+    list_class = py::bind_vector<NumericFluentList>(m, "NumericFluentList");
+    def_opaque_vector_repr<NumericFluentList>(list_class, "NumericFluentList");
 
     py::class_<EffectSimpleImpl>(m, "EffectSimple")  //
         .def("__str__", &EffectSimpleImpl::str)
@@ -650,7 +554,8 @@ void init_pymimir(py::module_& m)
         .def("get_index", &EffectSimpleImpl::get_index)
         .def("get_effect", &EffectSimpleImpl::get_effect, py::return_value_policy::reference_internal);
     static_assert(!py::detail::vector_needs_copy<EffectSimpleList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<EffectSimpleList>(m, "EffectSimpleList");
+    list_class = py::bind_vector<EffectSimpleList>(m, "EffectSimpleList");
+    def_opaque_vector_repr<EffectSimpleList>(list_class, "EffectSimpleList");
 
     py::class_<FunctionExpressionVariant>(m, "FunctionExpression")  //
         .def(
@@ -659,7 +564,8 @@ void init_pymimir(py::module_& m)
             py::keep_alive<0, 1>());
     ;
     static_assert(!py::detail::vector_needs_copy<FunctionExpressionVariantList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<FunctionExpressionVariantList>(m, "FunctionExpressionVariantList");
+    list_class = py::bind_vector<FunctionExpressionVariantList>(m, "FunctionExpressionVariantList");
+    def_opaque_vector_repr<FunctionExpressionVariantList>(list_class, "FunctionExpressionVariantList");
 
     py::class_<EffectComplexImpl>(m, "EffectComplex")  //
         .def("__str__", &EffectComplexImpl::str)
@@ -684,6 +590,7 @@ void init_pymimir(py::module_& m)
         .def("get_effect", &EffectComplexImpl::get_effect, py::return_value_policy::reference_internal);
     static_assert(!py::detail::vector_needs_copy<EffectComplexList>::value);  // Ensure return by reference + keep alive
     py::bind_vector<EffectComplexList>(m, "EffectComplexList");
+    def_opaque_vector_repr<EffectComplexList>(list_class, "EffectComplexList");
 
     py::class_<FunctionExpressionNumberImpl>(m, "FunctionExpressionNumber")  //
         .def("__str__", &FunctionExpressionNumberImpl::str)
@@ -726,7 +633,6 @@ void init_pymimir(py::module_& m)
             "get_function_expression",
             [](const FunctionExpressionMinusImpl& function_expression) { return FunctionExpressionVariant(function_expression.get_function_expression()); },
             py::keep_alive<0, 1>());
-    ;
 
     py::class_<FunctionExpressionFunctionImpl>(m, "FunctionExpressionFunction")  //
         .def("__str__", &FunctionExpressionFunctionImpl::str)
@@ -741,7 +647,8 @@ void init_pymimir(py::module_& m)
             py::keep_alive<0, 1>());
     ;
     static_assert(!py::detail::vector_needs_copy<GroundFunctionExpressionVariantList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundFunctionExpressionVariantList>(m, "GroundFunctionExpressionVariantList");
+    list_class = py::bind_vector<GroundFunctionExpressionVariantList>(m, "GroundFunctionExpressionVariantList");
+    def_opaque_vector_repr<GroundFunctionExpressionVariantList>(list_class, "GroundFunctionExpressionVariantList");
 
     py::class_<GroundFunctionExpressionNumberImpl>(m, "GroundFunctionExpressionNumber")  //
         .def("__str__", &GroundFunctionExpressionNumberImpl::str)
@@ -785,7 +692,6 @@ void init_pymimir(py::module_& m)
             [](const GroundFunctionExpressionMinusImpl& function_expression)
             { return GroundFunctionExpressionVariant(function_expression.get_function_expression()); },
             py::keep_alive<0, 1>());
-    ;
 
     py::class_<GroundFunctionExpressionFunctionImpl>(m, "GroundFunctionExpressionFunction")  //
         .def("__str__", &GroundFunctionExpressionFunctionImpl::str)
@@ -831,7 +737,8 @@ void init_pymimir(py::module_& m)
             py::keep_alive<0, 1>())
         .def("get_arity", &ActionImpl::get_arity);
     static_assert(!py::detail::vector_needs_copy<ActionList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<ActionList>(m, "ActionList");
+    list_class = py::bind_vector<ActionList>(m, "ActionList");
+    def_opaque_vector_repr<ActionList>(list_class, "ActionList");
 
     py::class_<AxiomImpl>(m, "Axiom")  //
         .def("__str__", &AxiomImpl::str)
@@ -852,7 +759,8 @@ void init_pymimir(py::module_& m)
             py::keep_alive<0, 1>())
         .def("get_arity", &AxiomImpl::get_arity);
     static_assert(!py::detail::vector_needs_copy<AxiomList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<AxiomList>(m, "AxiomList");
+    list_class = py::bind_vector<AxiomList>(m, "AxiomList");
+    def_opaque_vector_repr<AxiomList>(list_class, "AxiomList");
 
     py::class_<DomainImpl>(m, "Domain")  //
         .def("__str__", &DomainImpl::str)
@@ -901,7 +809,8 @@ void init_pymimir(py::module_& m)
             [](const DomainImpl& self) { return ToPredicateMap<std::string, Derived>(self.get_name_to_predicate<Derived>()); },
             py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<DomainList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<DomainList>(m, "DomainList");
+    list_class = py::bind_vector<DomainList>(m, "DomainList");
+    def_opaque_vector_repr<DomainList>(list_class, "DomainList");
 
     py::class_<ProblemImpl>(m, "Problem")  //
         .def("__str__", &ProblemImpl::str)
@@ -945,9 +854,41 @@ void init_pymimir(py::module_& m)
             [](const ProblemImpl& self) { return GroundLiteralList<Derived>(self.get_goal_condition<Derived>()); },
             py::keep_alive<0, 1>());
     static_assert(!py::detail::vector_needs_copy<ProblemList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<ProblemList>(m, "ProblemList");
+    list_class = py::bind_vector<ProblemList>(m, "ProblemList");
+    def_opaque_vector_repr<ProblemList>(list_class, "ProblemList");
+
+    py::class_<PDDLFactories, std::shared_ptr<PDDLFactories>> pddl_factories(m, "PDDLFactories");  //
 
-    py::class_<PDDLFactories, std::shared_ptr<PDDLFactories>>(m, "PDDLFactories")  //
+    pddl_factories
+        .def("get_ground_atoms",
+             [](py::object py_factory)  // we need an object handle to keep the factory alive for each atom in the list
+             {
+                 const auto& factory = py::cast<const PDDLFactories&>(py_factory);
+                 const auto& static_atom_factory = factory.get_factory<GroundAtomFactory<Static>>();
+                 const auto& fluent_atom_factory = factory.get_factory<GroundAtomFactory<Fluent>>();
+                 const auto& derived_atom_factory = factory.get_factory<GroundAtomFactory<Derived>>();
+
+                 py::list all_atoms(static_atom_factory.size() + fluent_atom_factory.size() + derived_atom_factory.size());
+                 size_t i = 0;
+                 auto append = [&](const auto& atoms)
+                 {
+                     for (const auto& atom : atoms)
+                     {
+                         py::object py_atom = py::cast(atom);
+                         if (!py_atom)
+                         {
+                             throw py::error_already_set();
+                         }
+                         py::detail::keep_alive_impl(py_atom, py_factory);
+                         all_atoms[i] = py::object { py_atom };
+                         i++;
+                     }
+                 };
+                 append(static_atom_factory);
+                 append(fluent_atom_factory);
+                 append(derived_atom_factory);
+                 return all_atoms;
+             })
         .def("get_static_ground_atom", &PDDLFactories::get_ground_atom<Static>, py::return_value_policy::reference_internal)
         .def("get_fluent_ground_atom", &PDDLFactories::get_ground_atom<Fluent>, py::return_value_policy::reference_internal)
         .def("get_derived_ground_atom", &PDDLFactories::get_ground_atom<Derived>, py::return_value_policy::reference_internal)
@@ -962,6 +903,25 @@ void init_pymimir(py::module_& m)
              py::keep_alive<0, 1>())
         .def("get_object", &PDDLFactories::get_object, py::return_value_policy::reference_internal);
 
+    auto bind_ground_atoms_range = [&]<typename Tag>(std::string_view func_name, Tag)
+    {
+        pddl_factories.def(
+            func_name.data(),
+            [=](const PDDLFactories& factory)
+            {
+                GroundAtomList<Tag> atoms;
+                for (const auto& elem : factory.get_factory<GroundAtomFactory<Tag>>())
+                {
+                    atoms.emplace_back(elem);
+                }
+                return atoms;
+            },
+            py::keep_alive<0, 1>());
+    };
+    bind_ground_atoms_range("get_static_ground_atoms", Static {});
+    bind_ground_atoms_range("get_fluent_ground_atoms", Fluent {});
+    bind_ground_atoms_range("get_derived_ground_atoms", Derived {});
+
     py::class_<PDDLParser>(m, "PDDLParser")  //
         .def(py::init<std::string, std::string>())
         .def("get_domain", &PDDLParser::get_domain, py::return_value_policy::reference_internal)
@@ -1036,7 +996,8 @@ void init_pymimir(py::module_& m)
             py::arg("pddl_factories"))
         .def("get_index", &State::get_index);
     static_assert(!py::detail::vector_needs_copy<StateList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<StateList>(m, "StateList");
+    list_class = py::bind_vector<StateList>(m, "StateList");
+    def_opaque_vector_repr<StateList>(list_class, "StateList");
     bind_const_span<std::span<const State>>(m, "StateSpan");
     bind_const_index_grouped_vector<IndexGroupedVector<const State>>(m, "IndexGroupedVector");
 
@@ -1076,17 +1037,19 @@ void init_pymimir(py::module_& m)
                  auto conditional_effects = std::vector<ConditionalEffect> {};
                  for (const auto& flat_conditional_effect : self.get_conditional_effects())
                  {
-                     conditional_effects.push_back(ConditionalEffect(flat_conditional_effect));
+                     conditional_effects.emplace_back(flat_conditional_effect);
                  }
                  return conditional_effects;
              });
     static_assert(!py::detail::vector_needs_copy<GroundActionList>::value);  // Ensure return by reference + keep alive
-    py::bind_vector<GroundActionList>(m, "GroundActionList");
+    list_class = py::bind_vector<GroundActionList>(m, "GroundActionList");
+    def_opaque_vector_repr<GroundActionList>(list_class, "GroundActionList");
     bind_const_span<std::span<const GroundAction>>(m, "GroundActionSpan");
 
     /* ApplicableActionGenerators */
 
-    py::class_<IApplicableActionGenerator, std::shared_ptr<IApplicableActionGenerator>>(m, "IApplicableActionGenerator")  //
+    py::class_<IApplicableActionGenerator, std::shared_ptr<IApplicableActionGenerator>>(m,
+                                                                                        "IApplicableActionGenerator")  //
         .def(
             "compute_applicable_actions",
             [](IApplicableActionGenerator& self, State state)
@@ -1129,7 +1092,8 @@ void init_pymimir(py::module_& m)
         .def(py::init<>());
     py::class_<DebugLiftedApplicableActionGeneratorEventHandler,
                ILiftedApplicableActionGeneratorEventHandler,
-               std::shared_ptr<DebugLiftedApplicableActionGeneratorEventHandler>>(m, "DebugLiftedApplicableActionGeneratorEventHandler")  //
+               std::shared_ptr<DebugLiftedApplicableActionGeneratorEventHandler>>(m,
+                                                                                  "DebugLiftedApplicableActionGeneratorEventHandler")  //
         .def(py::init<>());
     py::class_<LiftedApplicableActionGenerator, IApplicableActionGenerator, std::shared_ptr<LiftedApplicableActionGenerator>>(
         m,
@@ -1148,7 +1112,8 @@ void init_pymimir(py::module_& m)
         .def(py::init<>());
     py::class_<DebugGroundedApplicableActionGeneratorEventHandler,
                IGroundedApplicableActionGeneratorEventHandler,
-               std::shared_ptr<DebugGroundedApplicableActionGeneratorEventHandler>>(m, "DebugGroundedApplicableActionGeneratorEventHandler")  //
+               std::shared_ptr<DebugGroundedApplicableActionGeneratorEventHandler>>(m,
+                                                                                    "DebugGroundedApplicableActionGeneratorEventHandler")  //
         .def(py::init<>());
     py::class_<GroundedApplicableActionGenerator, IApplicableActionGenerator, std::shared_ptr<GroundedApplicableActionGenerator>>(
         m,
@@ -1159,8 +1124,11 @@ void init_pymimir(py::module_& m)
     /* StateRepository */
     py::class_<StateRepository, std::shared_ptr<StateRepository>>(m, "StateRepository")  //
         .def(py::init<std::shared_ptr<IApplicableActionGenerator>>())
-        .def("get_or_create_initial_state", &StateRepository::get_or_create_initial_state, py::keep_alive<0, 1>())    // keep_alive because value type
-        .def("get_or_create_state", &StateRepository::get_or_create_state, py::keep_alive<0, 1>(), py::arg("atoms"))  // keep_alive because value type
+        .def("get_or_create_initial_state", &StateRepository::get_or_create_initial_state, py::keep_alive<0, 1>())  // keep_alive because value type
+        .def("get_or_create_state",
+             &StateRepository::get_or_create_state,
+             py::keep_alive<0, 1>(),
+             py::arg("atoms"))  // keep_alive because value type
         .def("get_or_create_successor_state",
              &StateRepository::get_or_create_successor_state,
              py::keep_alive<0, 1>(),
@@ -1204,7 +1172,8 @@ void init_pymimir(py::module_& m)
         .def("get_num_expanded_until_f_value", &AStarAlgorithmStatistics::get_num_expanded_until_f_value)
         .def("get_num_deadends_until_f_value", &AStarAlgorithmStatistics::get_num_deadends_until_f_value)
         .def("get_num_pruned_until_f_value", &AStarAlgorithmStatistics::get_num_pruned_until_f_value);
-    py::class_<IAStarAlgorithmEventHandler, std::shared_ptr<IAStarAlgorithmEventHandler>>(m, "IAStarAlgorithmEventHandler")  //
+    py::class_<IAStarAlgorithmEventHandler, std::shared_ptr<IAStarAlgorithmEventHandler>>(m,
+                                                                                          "IAStarAlgorithmEventHandler")  //
         .def("get_statistics", &IAStarAlgorithmEventHandler::get_statistics);
     py::class_<DefaultAStarAlgorithmEventHandler, IAStarAlgorithmEventHandler, std::shared_ptr<DefaultAStarAlgorithmEventHandler>>(
         m,
@@ -1313,10 +1282,7 @@ void init_pymimir(py::module_& m)
         .def("__eq__", &StateVertex::operator==)
         .def("__hash__", [](const StateVertex& self) { return std::hash<StateVertex>()(self); })
         .def("get_index", &StateVertex::get_index)
-        .def(
-            "get_state",
-            [](const StateVertex& self) { return get_state(self); },
-            py::keep_alive<0, 1>());
+        .def("get_state", [](const StateVertex& self) { return get_state(self); }, py::keep_alive<0, 1>());
 
     // GroundActionEdge
     py::class_<GroundActionEdge>(m, "GroundActionEdge")  //
@@ -1326,10 +1292,7 @@ void init_pymimir(py::module_& m)
         .def("get_source", &GroundActionEdge::get_source)
         .def("get_target", &GroundActionEdge::get_target)
         .def("get_cost", [](const GroundActionEdge& self) { return get_cost(self); })
-        .def(
-            "get_creating_action",
-            [](const GroundActionEdge& self) { return get_creating_action(self); },
-            py::keep_alive<0, 1>());
+        .def("get_creating_action", [](const GroundActionEdge& self) { return get_creating_action(self); }, py::keep_alive<0, 1>());
 
     // GroundActionsEdge
     py::class_<GroundActionsEdge>(m, "GroundActionsEdge")  //
@@ -1343,10 +1306,7 @@ void init_pymimir(py::module_& m)
             "get_actions",
             [](const GroundActionsEdge& self) { return GroundActionList(get_actions(self).begin(), get_actions(self).end()); },
             py::keep_alive<0, 1>())
-        .def(
-            "get_representative_action",
-            [](const GroundActionsEdge& self) { return get_representative_action(self); },
-            py::keep_alive<0, 1>());
+        .def("get_representative_action", [](const GroundActionsEdge& self) { return get_representative_action(self); }, py::keep_alive<0, 1>());
 
     // StateSpace
     py::class_<StateSpaceOptions>(m, "StateSpaceOptions")
@@ -1572,10 +1532,7 @@ void init_pymimir(py::module_& m)
             "get_representative_state",
             [](const FaithfulAbstractStateVertex& self) { return get_representative_state(self); },
             py::keep_alive<0, 1>())
-        .def(
-            "get_certificate",
-            [](const FaithfulAbstractStateVertex& self) { return get_certificate(self); },
-            py::return_value_policy::reference_internal);
+        .def("get_certificate", [](const FaithfulAbstractStateVertex& self) { return get_certificate(self); }, py::return_value_policy::reference_internal);
 
     py::class_<FaithfulAbstraction, std::shared_ptr<FaithfulAbstraction>>(m, "FaithfulAbstraction")
         .def("__str__",
@@ -1952,10 +1909,7 @@ void init_pymimir(py::module_& m)
     py::class_<TupleGraphVertex>(m, "TupleGraphVertex")  //
         .def("get_index", &TupleGraphVertex::get_index)
         .def("get_tuple_index", &TupleGraphVertex::get_tuple_index)
-        .def(
-            "get_states",
-            [](const TupleGraphVertex& self) { return StateList(self.get_states()); },
-            py::keep_alive<0, 1>());
+        .def("get_states", [](const TupleGraphVertex& self) { return StateList(self.get_states()); }, py::keep_alive<0, 1>());
     bind_const_span<std::span<const TupleGraphVertex>>(m, "TupleGraphVertexSpan");
     bind_const_index_grouped_vector<IndexGroupedVector<const TupleGraphVertex>>(m, "TupleGraphVertexIndexGroupedVector");
 
@@ -2094,10 +2048,7 @@ void init_pymimir(py::module_& m)
 
     // ColorFunction
     py::class_<ColorFunction>(m, "ColorFunction")  //
-        .def(
-            "get_color_name",
-            [](const ColorFunction& self, Color color) -> const std::string& { return self.get_color_name(color); },
-            py::arg("color"));
+        .def("get_color_name", [](const ColorFunction& self, Color color) -> const std::string& { return self.get_color_name(color); }, py::arg("color"));
 
     // ProblemColorFunction
     py::class_<ProblemColorFunction, ColorFunction>(m, "ProblemColorFunction")  //
diff --git a/python/src/pymimir/init_declarations.hpp b/python/src/pymimir/init_declarations.hpp
new file mode 100644
index 000000000..ba337948e
--- /dev/null
+++ b/python/src/pymimir/init_declarations.hpp
@@ -0,0 +1,65 @@
+
+#ifndef MIMIR_INIT_DECLARATIONS_HPP
+#define MIMIR_INIT_DECLARATIONS_HPP
+
+#include "mimir/datasets/state_space.hpp"
+#include "mimir/formalism/atom.hpp"
+#include "mimir/formalism/declarations.hpp"
+#include "mimir/formalism/factories.hpp"
+#include "mimir/formalism/ground_atom.hpp"
+#include "mimir/formalism/object.hpp"
+#include "mimir/formalism/predicate.hpp"
+#include "mimir/formalism/predicate_category.hpp"
+#include "mimir/formalism/requirements.hpp"
+#include "mimir/formalism/term.hpp"
+#include "mimir/formalism/variable.hpp"
+#include "mimir/utils/utils.hpp"
+
+#include <fmt/format.h>
+#include <fmt/ostream.h>
+#include <fmt/ranges.h>
+#include <pybind11/detail/common.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>  // Necessary for automatic conversion of e.g. std::vectors
+#include <pybind11/stl_bind.h>
+
+#define STRINGIFY(x) #x
+#define MACRO_STRINGIFY(x) STRINGIFY(x)
+
+#include "mimir/mimir.hpp"
+
+//
+// utilities
+//
+
+struct default_elem_repr
+{
+    template<typename T>
+    std::string operator()(const T& elem) const
+    {
+        return mimir::deref(elem).str();
+    }
+};
+
+template<typename BoundVectorType, typename ReprFunctor = default_elem_repr>
+inline auto& def_opaque_vector_repr(auto& cls, const std::string& class_name, ReprFunctor elem_repr = ReprFunctor {})
+{
+    return cls;
+}
+
+template<typename BoundVectorType, typename ReprFunctor = default_elem_repr>
+    requires requires(typename BoundVectorType::value_type elem) {
+        { mimir::deref(elem).str() } -> std::convertible_to<std::string>;
+    }
+inline auto& def_opaque_vector_repr(auto& cls, const std::string& class_name, ReprFunctor elem_repr = ReprFunctor {})
+{
+    return cls.def("__str__",
+                   [=](const BoundVectorType& self) { return fmt::format("{}[{}]", class_name, fmt::join(std::views::transform(self, elem_repr), ",")); });
+}
+
+//
+// init - declarations:
+//
+void init_pymimir(pybind11::module_& m);
+
+#endif  // MIMIR_INIT_DECLARATIONS_HPP
diff --git a/python/src/pymimir/main.cpp b/python/src/pymimir/main.cpp
index dfbe67905..49136b4da 100644
--- a/python/src/pymimir/main.cpp
+++ b/python/src/pymimir/main.cpp
@@ -1,12 +1,10 @@
 #include <pybind11/pybind11.h>
 #include <pybind11/stl.h>  // Necessary for automatic conversion of e.g. std::vectors
+#include "init_declarations.hpp"
 
 #define STRINGIFY(x) #x
 #define MACRO_STRINGIFY(x) STRINGIFY(x)
 
-namespace py = pybind11;
-
-void init_pymimir(py::module_&);
 
 PYBIND11_MODULE(_pymimir, m)
 {
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 0188cbf58..97421c10a 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -35,6 +35,7 @@ add_library(mimir::core ALIAS core)
 target_link_libraries(core loki::parsers)
 target_link_libraries(core Nauty::Nauty)
 target_link_libraries(core Boost::iostreams ZLIB::ZLIB)
+target_link_libraries(core fmt::fmt)
 
 # Use include depending on building or using from installed location
 target_include_directories(core


Test patch:
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 059f2a250..b2e4be463 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,1 +1,4 @@
-add_subdirectory(unit)
\ No newline at end of file
+add_subdirectory(unit)
+if (BUILD_PYMIMIR)
+    add_subdirectory(python)
+endif ()
\ No newline at end of file
diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt
new file mode 100644
index 000000000..6b6776061
--- /dev/null
+++ b/tests/python/CMakeLists.txt
@@ -0,0 +1,19 @@
+find_package(GTest "1.11.0" REQUIRED COMPONENTS GTest Main PATHS ${CMAKE_PREFIX_PATH} NO_DEFAULT_PATH)
+# Set result variables
+find_package(GTest)
+
+find_package(pybind11)
+
+add_executable(pymimir_tests
+        main_tests.cpp
+        test_bindings.cpp
+        )
+
+target_link_libraries(pymimir_tests
+        PRIVATE
+        GTest::gtest
+        pybind11::embed
+        _pymimir_objects
+        )
+
+add_test(NAME pymimir_tests COMMAND pymimir_tests)
\ No newline at end of file
diff --git a/tests/python/fixture.hpp b/tests/python/fixture.hpp
new file mode 100644
index 000000000..0b0e9878a
--- /dev/null
+++ b/tests/python/fixture.hpp
@@ -0,0 +1,32 @@
+#ifndef MIMIR_PYTHON_TEST_FIXTURE_HPP
+#define MIMIR_PYTHON_TEST_FIXTURE_HPP
+
+#include "init_declarations.hpp"
+
+#include <gtest/gtest.h>
+#include <pybind11/embed.h>  // embed python interpreter to use from c++
+#include <pybind11/pybind11.h>
+
+// necessary to replicate the module here to use it in-place (verify against python/src/mimir/main.cpp)
+PYBIND11_MODULE(_pymimir, m) { init_pymimir(m); }
+
+PyMODINIT_FUNC PyInit__pymimir();
+
+struct PymimirFixture : public ::testing::Test
+{
+    PymimirFixture()
+    {
+        PyImport_AppendInittab("_pymimir", &PyInit__pymimir);
+        guard = std::make_unique<pybind11::scoped_interpreter>();
+        m_pymimir = std::make_unique<pybind11::module>(pybind11::module::import("_pymimir"));
+    }
+
+    const auto& pymimir() const { return *m_pymimir; }
+
+private:
+    // unique ptr to ensure that the interpreter can be instantiated after PyImport_AppendInittab("pymimir",...)
+    std::unique_ptr<pybind11::scoped_interpreter> guard = nullptr;
+    std::unique_ptr<pybind11::module> m_pymimir = nullptr;
+};
+
+#endif  // MIMIR_PYTHON_TEST_FIXTURE_HPP
diff --git a/tests/python/main_tests.cpp b/tests/python/main_tests.cpp
new file mode 100644
index 000000000..2f12ee820
--- /dev/null
+++ b/tests/python/main_tests.cpp
@@ -0,0 +1,8 @@
+#include <gtest/gtest.h>
+
+
+int main(int argc, char** argv)
+{
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
\ No newline at end of file
diff --git a/tests/python/test_bindings.cpp b/tests/python/test_bindings.cpp
new file mode 100644
index 000000000..8918b87ad
--- /dev/null
+++ b/tests/python/test_bindings.cpp
@@ -0,0 +1,60 @@
+#include "fixture.hpp"
+
+#include <gtest/gtest.h>
+#include <pybind11/pybind11.h>
+
+namespace py = pybind11;
+
+constexpr std::string_view project_dir = "../../../";
+
+TEST_F(PymimirFixture, get_ground_atoms)
+{
+    auto parser =
+        pymimir().attr("PDDLParser")(std::string(project_dir) + "data/blocks_4/domain.pddl", std::string(project_dir) + "data/blocks_4/test_problem.pddl");
+    auto factory = parser.attr("get_pddl_factories")();
+    auto static_atoms = factory.attr("get_static_ground_atoms")();
+    EXPECT_EQ(py::len(static_atoms), 0);
+    auto derived_atoms = factory.attr("get_derived_ground_atoms")();
+    EXPECT_EQ(py::len(derived_atoms), 0);
+    auto fluent_atoms_from_ind = factory.attr("get_fluent_ground_atoms_from_indices")(py::eval("range")(0, 8));
+    EXPECT_EQ(py::len(fluent_atoms_from_ind), 8);
+    auto fluent_atoms = factory.attr("get_fluent_ground_atoms")();
+    EXPECT_EQ(py::len(fluent_atoms), 8);
+    auto atoms = factory.attr("get_ground_atoms")();
+    EXPECT_GE(py::len(atoms), py::len(fluent_atoms) + py::len(derived_atoms) + py::len(static_atoms));
+}
+
+TEST_F(PymimirFixture, parser_factory_atom_lifetime)
+{
+    auto parser =
+        pymimir().attr("PDDLParser")(std::string(project_dir) + "data/blocks_4/domain.pddl", std::string(project_dir) + "data/blocks_4/test_problem.pddl");
+    auto factory = parser.attr("get_pddl_factories")();
+    auto atoms = factory.attr("get_ground_atoms")();
+    auto& internals = py::detail::get_internals();
+    for (auto atom : py::cast<py::list>(atoms))
+    {
+        const auto& patients = internals.patients[atom.ptr()];
+        EXPECT_TRUE(std::find(patients.begin(), patients.end(), factory.ptr()) != patients.end());
+    }
+}
+
+TEST_F(PymimirFixture, test_list_repr)
+{
+    auto parser =
+        pymimir().attr("PDDLParser")(std::string(project_dir) + "data/blocks_4/domain.pddl", std::string(project_dir) + "data/blocks_4/test_problem.pddl");
+    auto factory = parser.attr("get_pddl_factories")();
+    auto atoms = factory.attr("get_fluent_ground_atoms")();
+    auto str = py::str(atoms).cast<std::string>();
+#ifndef NDEBUG
+    fmt::println("{}", str);
+#endif
+    EXPECT_TRUE(str.find("FluentGroundAtomList") != std::string::npos);
+    for (auto atom : py::cast<py::list>(atoms))
+    {
+        auto atom_str = py::str(atom).cast<std::string>();
+#ifndef NDEBUG
+        fmt::println("{}", atom_str);
+#endif
+        EXPECT_TRUE(str.find(atom_str) != std::string::npos);
+    }
+}
\ No newline at end of file