diff --git a/.gitignore b/.gitignore
index 1c8d2d8653f4f5f011ee4b7c7bb26eef884e9927..d3e5862f91cbcc6aed0fbda53363fca53c28d345 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,4 +38,9 @@ CMakeUserPresets.json
 nohup.out
 
 /Documentation/.vale
+
+# Python build
+/_skbuild
+*.egg-info/
+/wheelhouse
 .ipynb_checkpoints
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e4fe9bb3d189d2b0c7d7ea03d69b8a18b35a2e69..b92c02dcc3681610f4930a0d31402fcffbfab873 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -37,6 +37,7 @@ include:
   - local: "/scripts/ci/jobs/build-win.yml"
   - local: "/scripts/ci/jobs/build-mac.yml"
   - local: "/scripts/ci/jobs/build-container.yml"
+  - local: "/scripts/ci/jobs/build-wheels.yml"
   - local: "/scripts/ci/jobs/jupyter.yml"
   - local: "/scripts/ci/jobs/code-quality.yml"
   - local: "/scripts/ci/jobs/code-coverage.yml"
diff --git a/Applications/CLI/CMakeLists.txt b/Applications/CLI/CMakeLists.txt
index 742e4be2a96c01cf190221b8c9e64dcbf026bd86..4d221c96d5da053f8c21ad450e256b0d574c9c5e 100644
--- a/Applications/CLI/CMakeLists.txt
+++ b/Applications/CLI/CMakeLists.txt
@@ -45,31 +45,6 @@ if(OGS_USE_PYTHON)
                $<$<BOOL:${BUILD_SHARED_LIBS}>:PRIVATE
                OGS_BUILD_SHARED_LIBS>
     )
-
-    # Create OpenGeoSys python module
-    # https://pybind11.readthedocs.io/en/stable/compiling.html#building-with-cmake
-    if(OGS_BUILD_PYTHON_MODULE)
-        pybind11_add_module(
-            OpenGeoSys MODULE ogs_python_module.cpp
-            CommandLineArgumentParser.cpp
-        )
-
-        # lld linker strips out PyInit_OpenGeoSys symbol. Use standard linker.
-        get_target_property(_link_options OpenGeoSys LINK_OPTIONS)
-        if(_link_options)
-            list(REMOVE_ITEM _link_options -fuse-ld=lld)
-            set_target_properties(
-                OpenGeoSys PROPERTIES LINK_OPTIONS "${_link_options}"
-            )
-        endif()
-
-        target_link_libraries(
-            OpenGeoSys PRIVATE ApplicationsLib BaseLib GitInfoLib tclap
-                               pybind11::pybind11
-        )
-
-        install(TARGETS OpenGeoSys LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
-    endif()
 endif()
 
 ogs_add_executable(ogs ogs.cpp CommandLineArgumentParser.cpp)
@@ -94,28 +69,6 @@ target_link_libraries(
 add_test(NAME ogs_no_args COMMAND ogs)
 set_tests_properties(ogs_no_args PROPERTIES WILL_FAIL TRUE LABELS "default")
 
-if(OGS_BUILD_PYTHON_MODULE AND NOT (WIN32 AND "${CMAKE_BUILD_TYPE}" STREQUAL
-                                              "Release")
-)
-    # TODO: does not work on Windows Release
-    add_test(
-        NAME ogs_python_module
-        COMMAND
-            ${Python_EXECUTABLE}
-            ${CMAKE_CURRENT_SOURCE_DIR}/ogs_python_module.py
-            ${CMAKE_SOURCE_DIR}/Tests/Data/Parabolic/LiquidFlow/Flux/cube_1e3_calculatesurfaceflux.prj
-    )
-    set_tests_properties(
-        ogs_python_module
-        PROPERTIES
-            LABELS
-            "default"
-            ENVIRONMENT_MODIFICATION
-            PYTHONPATH=path_list_append:$<TARGET_FILE_DIR:OpenGeoSys>:${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}
-            DISABLED
-            $<NOT:$<TARGET_EXISTS:LiquidFlow>>
-    )
-endif()
 # ---- Installation ----
 install(TARGETS ogs RUNTIME DESTINATION bin)
 
diff --git a/Applications/CLI/ogs_python_module.py b/Applications/CLI/ogs_python_module.py
deleted file mode 100755
index 8f553584fddfd89015472bb9579f18d077175392..0000000000000000000000000000000000000000
--- a/Applications/CLI/ogs_python_module.py
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env python3
-
-# The OpenGeoSys python library will be created in the lib folder of the build
-# directory
-## export PYTHONPATH=$PYTHONPATH:your-build_directory-here/lib
-
-import sys
-import tempfile
-import OpenGeoSys
-
-arguments = ["", sys.argv[1], "-o " + tempfile.mkdtemp()]
-
-print("Python OpenGeoSys.init ...")
-OpenGeoSys.initialize(arguments)
-print("Python OpenGeoSys.executeSimulation ...")
-OpenGeoSys.executeSimulation()
-print("Python OpenGeoSys.finalize() ...")
-OpenGeoSys.finalize()
-print("Python world.")
diff --git a/Applications/CMakeLists.txt b/Applications/CMakeLists.txt
index 386e8b3f40d4d3b90e7668ebfe052e44726e618c..ce44d2681a92b9a9538463e40ba303b4f1248cac 100644
--- a/Applications/CMakeLists.txt
+++ b/Applications/CMakeLists.txt
@@ -19,3 +19,7 @@ endif() # OGS_BUILD_CLI
 if(OGS_USE_INSITU)
     add_subdirectory(InSituLib)
 endif()
+
+if(OGS_BUILD_PYTHON_MODULE)
+    add_subdirectory(Python)
+endif()
diff --git a/Applications/Python/CMakeLists.txt b/Applications/Python/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..667d1aff7032bbd09a365db27bfb44d5d29c591d
--- /dev/null
+++ b/Applications/Python/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(ogs.simulator)
diff --git a/Applications/Python/ogs.simulator/CMakeLists.txt b/Applications/Python/ogs.simulator/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..dce167cd59f62621b40153457b2f477b0b094e54
--- /dev/null
+++ b/Applications/Python/ogs.simulator/CMakeLists.txt
@@ -0,0 +1,21 @@
+# Create OpenGeoSys python module
+# https://pybind11.readthedocs.io/en/stable/compiling.html#building-with-cmake
+pybind11_add_module(
+    simulator MODULE ogs_python_module.cpp
+    ../../CLI/CommandLineArgumentParser.cpp
+)
+
+# lld linker strips out PyInit_OpenGeoSys symbol. Use standard linker.
+get_target_property(_link_options simulator LINK_OPTIONS)
+if(_link_options)
+    list(REMOVE_ITEM _link_options -fuse-ld=lld)
+    set_target_properties(simulator PROPERTIES LINK_OPTIONS "${_link_options}")
+endif()
+
+target_link_libraries(
+    simulator PRIVATE ApplicationsLib BaseLib CMakeInfoLib GitInfoLib tclap
+)
+target_include_directories(simulator PRIVATE ../../CLI)
+
+# Install into root dir (in Python module, enables 'import ogs.simulator')
+install(TARGETS simulator LIBRARY DESTINATION .)
diff --git a/Applications/CLI/ogs_python_module.cpp b/Applications/Python/ogs.simulator/ogs_python_module.cpp
similarity index 98%
rename from Applications/CLI/ogs_python_module.cpp
rename to Applications/Python/ogs.simulator/ogs_python_module.cpp
index 9abbe7a727d9d838a89bd19a673ef2175a30750d..ab4103c7c6579c210fc7e9aa095dd9ac73c0d81c 100644
--- a/Applications/CLI/ogs_python_module.cpp
+++ b/Applications/Python/ogs.simulator/ogs_python_module.cpp
@@ -122,8 +122,9 @@ void finalize()
 }
 
 /// python module name is OpenGeoSys
-PYBIND11_MODULE(OpenGeoSys, m)
+PYBIND11_MODULE(simulator, m)
 {
+    m.attr("__name__") = "ogs.simulator";
     m.doc() = "pybind11 ogs example plugin";
     m.def("initialize", &initOGS, "init OGS");
     m.def("currentTime", &currentTime, "get current OGS time");
diff --git a/Applications/Python/ogs/__init__.py b/Applications/Python/ogs/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/Applications/Python/ogs/_internal/__init__.py b/Applications/Python/ogs/_internal/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/Applications/Python/ogs/_internal/provide_ogs_cli_tools_via_wheel.py b/Applications/Python/ogs/_internal/provide_ogs_cli_tools_via_wheel.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea18d3437538f4ddfa38f4c12b614b70624cb818
--- /dev/null
+++ b/Applications/Python/ogs/_internal/provide_ogs_cli_tools_via_wheel.py
@@ -0,0 +1,80 @@
+import os
+import platform
+import subprocess
+import sys
+
+binaries_list = [
+    "addDataToRaster",
+    "AddElementQuality",
+    "AddFaultToVoxelGrid",
+    "AddLayer",
+    "appendLinesAlongPolyline",
+    "AssignRasterDataToMesh",
+    "checkMesh",
+    "ComputeNodeAreasFromSurfaceMesh",
+    "computeSurfaceNodeIDsInPolygonalRegion",
+    "constructMeshesFromGeometry",
+    "convertGEO",
+    "convertToLinearMesh",
+    "convertVtkDataArrayToVtkDataArray",
+    "CreateBoundaryConditionsAlongPolylines",
+    "createIntermediateRasters",
+    "createLayeredMeshFromRasters",
+    "createMeshElemPropertiesFromASCRaster",
+    "createNeumannBc",
+    "createQuadraticMesh",
+    "createRaster",
+    "editMaterialID",
+    "ExtractBoundary",
+    "ExtractMaterials",
+    "ExtractSurface",
+    "generateGeometry",
+    "generateMatPropsFromMatID",
+    "generateStructuredMesh",
+    "geometryToGmshGeo",
+    "GMSH2OGS",
+    "GocadSGridReader",
+    "GocadTSurfaceReader",
+    "identifySubdomains",
+    "IntegrateBoreholesIntoMesh",
+    "Layers2Grid",
+    "MapGeometryToMeshSurface",
+    "Mesh2Raster",
+    "MoveGeometry",
+    "MoveMesh",
+    "moveMeshNodes",
+    "mpmetis",
+    "NodeReordering",
+    "ogs",
+    "OGS2VTK",
+    "partmesh",
+    "PVD2XDMF",
+    "queryMesh",
+    "Raster2Mesh",
+    "RemoveGhostData",
+    "removeMeshElements",
+    "ResetPropertiesInPolygonalRegion",
+    "reviseMesh",
+    "scaleProperty",
+    "swapNodeCoordinateAxes",
+    "TecPlotTools",
+    "tetgen",
+    "TIN2VTK",
+    "VTK2OGS",
+    "VTK2TIN",
+    "vtkdiff",
+    "Vtu2Grid",
+]
+
+if "PEP517_BUILD_BACKEND" not in os.environ:
+    OGS_BIN_DIR = os.path.join(os.path.join(os.path.dirname(__file__), "..", "bin"))
+
+    if platform.system() == "Windows":
+        os.add_dll_directory(OGS_BIN_DIR)
+
+    def _program(name, args):
+        return subprocess.run([os.path.join(OGS_BIN_DIR, name)] + args).returncode
+
+    FUNC_TEMPLATE = """def {0}(): raise SystemExit(_program("{0}", sys.argv[1:]))"""
+    for f in binaries_list:
+        exec(FUNC_TEMPLATE.format(f))
diff --git a/Applications/Utils/GeoTools/addDataToRaster.cpp b/Applications/Utils/GeoTools/addDataToRaster.cpp
index f21fc93f952009dd6cc17c8466dbc81d2b26a7ca..92e3ef9d337ca98ab19be311be01ef4d6579a3b2 100644
--- a/Applications/Utils/GeoTools/addDataToRaster.cpp
+++ b/Applications/Utils/GeoTools/addDataToRaster.cpp
@@ -24,6 +24,7 @@
 #include "GeoLib/AABB.h"
 #include "GeoLib/Point.h"
 #include "GeoLib/Raster.h"
+#include "InfoLib/GitInfo.h"
 
 double compute2DGaussBellCurveValues(GeoLib::Point const& point,
                                      GeoLib::AABB const& aabb)
@@ -51,7 +52,14 @@ double computeSinXSinY(GeoLib::Point const& point, GeoLib::AABB const& aabb)
 
 int main(int argc, char* argv[])
 {
-    TCLAP::CmdLine cmd("Add values to raster.", ' ', "0.1");
+    TCLAP::CmdLine cmd(
+        "Add values to raster.\n\n"
+        "OpenGeoSys-6 software, version " +
+            GitInfoLib::GitInfo::ogs_version +
+            ".\n"
+            "Copyright (c) 2012-2022, OpenGeoSys Community "
+            "(http://www.opengeosys.org)",
+        ' ', GitInfoLib::GitInfo::ogs_version);
 
     TCLAP::ValueArg<std::string> out_raster_arg(
         "o",
diff --git a/Applications/Utils/GeoTools/createRaster.cpp b/Applications/Utils/GeoTools/createRaster.cpp
index d58e7c290366d7c43102dadf6eab228ee9d03f3d..c9a521c53d54568365cfff074b4c91cb83da995d 100644
--- a/Applications/Utils/GeoTools/createRaster.cpp
+++ b/Applications/Utils/GeoTools/createRaster.cpp
@@ -20,11 +20,18 @@
 #include "GeoLib/AABB.h"
 #include "GeoLib/Point.h"
 #include "GeoLib/Raster.h"
+#include "InfoLib/GitInfo.h"
 
 int main(int argc, char* argv[])
 {
-    TCLAP::CmdLine cmd("Create a raster where every pixel is zero.", ' ',
-                       "0.1");
+    TCLAP::CmdLine cmd(
+        "computeSurfaceNodeIDsInPolygonalRegion\n\n"
+        "OpenGeoSys-6 software, version " +
+            GitInfoLib::GitInfo::ogs_version +
+            ".\n"
+            "Copyright (c) 2012-2022, OpenGeoSys Community "
+            "(http://www.opengeosys.org)",
+        ' ', GitInfoLib::GitInfo::ogs_version);
 
     TCLAP::ValueArg<std::string> output_arg("o", "output",
                                             "Name of the output raster (*.asc)",
diff --git a/Applications/Utils/MeshEdit/appendLinesAlongPolyline.cpp b/Applications/Utils/MeshEdit/appendLinesAlongPolyline.cpp
index 0774cc64529f8bb40bdf529dbfdf6d15257b5797..1b6625b70fed37ca7b93144392a9cacd9fdfa33f 100644
--- a/Applications/Utils/MeshEdit/appendLinesAlongPolyline.cpp
+++ b/Applications/Utils/MeshEdit/appendLinesAlongPolyline.cpp
@@ -50,7 +50,7 @@ int main(int argc, char* argv[])
         "the name of the geometry file");
     cmd.add(geoFileArg);
 
-    TCLAP::ValueArg<std::string> gmsh_path_arg("g", "gmsh-path",
+    TCLAP::ValueArg<std::string> gmsh_path_arg("", "gmsh-path",
                                                "the path to the gmsh binary",
                                                false, "", "path as string");
     cmd.add(gmsh_path_arg);
diff --git a/Applications/Utils/MeshGeoTools/computeSurfaceNodeIDsInPolygonalRegion.cpp b/Applications/Utils/MeshGeoTools/computeSurfaceNodeIDsInPolygonalRegion.cpp
index b628eceb948208b963ac90e86dcf2e7a9b14fdd1..355c00e3269973ce4554dadae6a2c24898bd2e25 100644
--- a/Applications/Utils/MeshGeoTools/computeSurfaceNodeIDsInPolygonalRegion.cpp
+++ b/Applications/Utils/MeshGeoTools/computeSurfaceNodeIDsInPolygonalRegion.cpp
@@ -94,7 +94,7 @@ int main(int argc, char* argv[])
         true, "", "file name of input geometry");
     cmd.add(geo_in);
 
-    TCLAP::ValueArg<std::string> gmsh_path_arg("g", "gmsh-path",
+    TCLAP::ValueArg<std::string> gmsh_path_arg("", "gmsh-path",
                                                "the path to the gmsh binary",
                                                false, "", "path as string");
     cmd.add(gmsh_path_arg);
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7c4027cd17f12a4a3a5fd82ee0ea79c36ebf6b8f..556122f6a8bb093f3921eef31f5137aad454322f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -4,12 +4,9 @@ cmake_minimum_required(VERSION 3.22)
 project(OGS-6)
 
 option(OGS_USE_PYTHON "Interface with Python" ON)
-include(CMakeDependentOption)
-cmake_dependent_option(
-    OGS_BUILD_PYTHON_MODULE "Should the OGS Python module be built?" ON
-    "OGS_USE_PYTHON" OFF
-)
+option(OGS_BUILD_PYTHON_MODULE "Should the OGS Python module be built?" ON)
 
+include(CMakeDependentOption)
 include(scripts/cmake/DownloadCpmCache.cmake)
 include(scripts/cmake/CPM.cmake)
 include(scripts/cmake/CMakeSetup.cmake)
diff --git a/CMakePresets.json b/CMakePresets.json
index 703fc056a2c690b8f281b6112913d6e7b9bcf048..583bde18d31873396e0a17f1a4f1acb9f227c9c1 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -187,6 +187,39 @@
         "OGS_USE_PYTHON": "OFF",
         "OGS_USE_UNITY_BUILDS": "OFF"
       }
+    },
+    {
+      "name": "wheel",
+      "inherits": "release",
+      "cacheVariables": {
+        "HDF5_USE_STATIC_LIBRARIES": "ON",
+        "OGS_BUILD_HDF5": "ON",
+        "OGS_USE_PYTHON": "OFF",
+        "OGS_BUILD_PYTHON_MODULE": "ON",
+        "OGS_BUILD_TESTING": "OFF",
+        "OGS_INSTALL_DEPENDENCIES": "OFF",
+        "OGS_USE_PIP": "OFF",
+        "OGS_USE_MFRONT": "ON",
+        "BUILD_SHARED_LIBS": "ON"
+      },
+      "condition": {
+        "type": "notEquals",
+        "lhs": "${hostSystemName}",
+        "rhs": "Windows"
+      }
+    },
+    {
+      "name": "wheel-win",
+      "inherits": "wheel",
+      "cacheVariables": {
+        "OGS_USE_MFRONT": "OFF",
+        "OGS_BUILD_PROCESS_TH2M": "OFF"
+      },
+      "condition": {
+        "type": "equals",
+        "lhs": "${hostSystemName}",
+        "rhs": "Windows"
+      }
     }
   ],
   "buildPresets": [
@@ -270,6 +303,14 @@
     {
       "name": "ci-no-unity",
       "configurePreset": "ci-no-unity"
+    },
+    {
+      "name": "wheel",
+      "configurePreset": "wheel"
+    },
+    {
+      "name": "wheel-win",
+      "configurePreset": "wheel-win"
     }
   ],
   "testPresets": [
diff --git a/MaterialLib/SolidModels/Ehlers.cpp b/MaterialLib/SolidModels/Ehlers.cpp
index 28adfbd6cae9f8bf251da442a675e9dfd200038c..83995b289553f8be4b9e58939eb92ae414dba657 100644
--- a/MaterialLib/SolidModels/Ehlers.cpp
+++ b/MaterialLib/SolidModels/Ehlers.cpp
@@ -842,6 +842,9 @@ SolidEhlers<DisplacementDim>::getInternalVariables() const
 template class SolidEhlers<2>;
 template class SolidEhlers<3>;
 
+template struct StateVariables<2>;
+template struct StateVariables<3>;
+
 template <>
 MathLib::KelvinVector::KelvinMatrixType<3> sOdotS<3>(
     MathLib::KelvinVector::KelvinVectorType<3> const& v)
diff --git a/MaterialLib/SolidModels/Ehlers.h b/MaterialLib/SolidModels/Ehlers.h
index 9c9d3ec2bfb46ae289249c34fd30af7d27650fd7..d984878cf1397093c1fa8409c7c63f3c80cc43c1 100644
--- a/MaterialLib/SolidModels/Ehlers.h
+++ b/MaterialLib/SolidModels/Ehlers.h
@@ -24,11 +24,10 @@
 
 #include "BaseLib/Error.h"
 #include "MathLib/KelvinVector.h"
+#include "MechanicsBase.h"
 #include "NumLib/NewtonRaphson.h"
 #include "ParameterLib/Parameter.h"
 
-#include "MechanicsBase.h"
-
 namespace MaterialLib
 {
 namespace Solids
@@ -250,8 +249,8 @@ struct StateVariables
     Damage damage_prev;                      ///< \copydoc damage
 
 #ifndef NDEBUG
-    friend std::ostream& operator<<(
-        std::ostream& os, StateVariables<DisplacementDim> const& m)
+    friend std::ostream& operator<<(std::ostream& os,
+                                    StateVariables<DisplacementDim> const& m)
     {
         os << "State:\n"
            << "eps_p_D: " << m.eps_p.D << "\n"
@@ -358,6 +357,9 @@ private:
 
 extern template class SolidEhlers<2>;
 extern template class SolidEhlers<3>;
+
+extern template struct StateVariables<2>;
+extern template struct StateVariables<3>;
 }  // namespace Ehlers
 }  // namespace Solids
 }  // namespace MaterialLib
diff --git a/Tests/Python/__init__.py b/Tests/Python/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd1659acb19806c66fa2d73fc29c2cdbcb384a23
--- /dev/null
+++ b/Tests/Python/__init__.py
@@ -0,0 +1,11 @@
+import sys
+
+from contextlib import contextmanager
+
+
+@contextmanager
+def push_argv(argv):
+    old_argv = sys.argv
+    sys.argv = argv
+    yield
+    sys.argv = old_argv
diff --git a/Tests/Python/test_cli.py b/Tests/Python/test_cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9d23bb8d7949c423ef8ea406cc1720dd09bfeca
--- /dev/null
+++ b/Tests/Python/test_cli.py
@@ -0,0 +1,23 @@
+import tempfile
+import os
+
+import pytest
+
+import ogs._internal.provide_ogs_cli_tools_via_wheel as ogs_cli_wheel
+
+from . import push_argv
+
+
+def _run(program, args):
+    func = getattr(ogs_cli_wheel, program)
+    args = ["%s.py" % program] + args
+    with push_argv(args), pytest.raises(SystemExit) as excinfo:
+        func()
+    assert 0 == excinfo.value.code
+
+
+def test_binaries():
+    ignore_list = ["moveMeshNodes", "mpmetis", "tetgen"]  # have no --version cli flag
+    for f in ogs_cli_wheel.binaries_list:
+        if f not in ignore_list:
+            _run(f, ["--version"])
diff --git a/Tests/Python/test_simlator.py b/Tests/Python/test_simlator.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb41cad5e08748f36fa0b3b4e2c88f1cae21b667
--- /dev/null
+++ b/Tests/Python/test_simlator.py
@@ -0,0 +1,20 @@
+import tempfile
+import os
+
+import pytest
+import ogs.simulator as sim
+
+
+def test_simulator():
+    arguments = [
+        "",
+        f"{os.path.abspath(os.path.dirname(__file__))}/../Data/Parabolic/LiquidFlow/Flux/cube_1e3_calculatesurfaceflux.prj",
+        "-o " + tempfile.mkdtemp(),
+    ]
+
+    print("Python OpenGeoSys.init ...")
+    sim.initialize(arguments)
+    print("Python OpenGeoSys.executeSimulation ...")
+    sim.executeSimulation()
+    print("Python OpenGeoSys.finalize() ...")
+    sim.finalize()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..5dc4a1ce709360a65a5112869c60e45553ec715b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,32 @@
+[build-system]
+requires = [
+  "setuptools>=42",
+  "scikit-build @ git+https://github.com/bilke/scikit-build/@disable-cmake-install-check#egg=scikit-build ; platform_system == 'Windows'",
+  "scikit-build>=0.15.0 ; platform_system != 'Windows'",
+  "cmake>=3.22",
+  "ninja ; platform_system != 'Windows'",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+testpaths = ["Tests"]
+norecursedirs = ["Tests/Data"]
+
+[tool.cibuildwheel]
+archs = "auto64"
+build = "cp3*"
+test-extras = "test"
+test-command = "pytest {project}/Tests/Python"
+build-verbosity = "1"
+
+[tool.cibuildwheel.linux]
+skip = ["*musllinux*", "cp36-*"]
+manylinux-x86_64-image = "manylinux2014"
+manylinux-aarch64-image = "manylinux2014"
+environment-pass = ["OGS_VERSION"]
+
+[tool.cibuildwheel.macos]
+skip = ["cp36-*", "cp37-*", "cp38-*x86_64"]
+
+[tool.cibuildwheel.windows]
+skip = ["cp36-*", "cp37-*"]
diff --git a/scripts/ci/jobs/build-wheels.yml b/scripts/ci/jobs/build-wheels.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b43eb0402b7e51f2c1d76223b09798f5f48302f8
--- /dev/null
+++ b/scripts/ci/jobs/build-wheels.yml
@@ -0,0 +1,32 @@
+.wheels_template: &wheels_template
+  stage: build
+  needs: [meta]
+  script:
+    - pipx run cibuildwheel
+  rules:
+    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
+    - if: $CI_COMMIT_TAG
+  artifacts:
+    paths:
+      - wheelhouse/
+
+build wheels linux:
+  tags: [envinf3-shell]
+  <<: *wheels_template
+
+build wheels mac:
+  tags:
+    - mac
+    - ${ARCHITECTURE}
+  variables:
+    CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
+  parallel:
+    matrix:
+      - ARCHITECTURE: ["amd64", "arm64"]
+  <<: *wheels_template
+
+build wheels win:
+  tags: [windows]
+  extends:
+    - .vs2019-environment
+  <<: *wheels_template
diff --git a/scripts/ci/jobs/jupyter.yml b/scripts/ci/jobs/jupyter.yml
index 875e30ff859caf1e419792e1ec2e05e0458cafb9..039cd85185c64f1bd383a6122a80a99865a0914e 100644
--- a/scripts/ci/jobs/jupyter.yml
+++ b/scripts/ci/jobs/jupyter.yml
@@ -1,7 +1,7 @@
 # Built for Sandy Bridge (envinf1) and newer
 build jupyter:
   stage: build
-  tags: [envinf, shell]
+  tags: [envinf23, shell]
   needs: [meta]
   extends:
     - .container-maker-setup
diff --git a/scripts/ci/jobs/pre-commit.yml b/scripts/ci/jobs/pre-commit.yml
index 292e826aeab859a398c573107eff8160e18064ff..10672766a167809a2f398d630c930228b6c85b5a 100644
--- a/scripts/ci/jobs/pre-commit.yml
+++ b/scripts/ci/jobs/pre-commit.yml
@@ -20,7 +20,7 @@ clang-format:
   needs: [ci_images]
   allow_failure: true
   script:
-    - git clang-format ${CI_MERGE_REQUEST_TARGET_BRANCH_SHA}
+    - git clang-format --extensions "h,cpp" ${CI_MERGE_REQUEST_TARGET_BRANCH_SHA}
     - if [[ $(git diff) ]]; then exit 1; fi
   after_script:
     - git diff
diff --git a/scripts/ci/jobs/release.yml b/scripts/ci/jobs/release.yml
index 81bba47813ab17c3aa4593e76b5384b931ff3b44..bb2581ec1cadac5b05bbfda649bef5ebb556f50e 100644
--- a/scripts/ci/jobs/release.yml
+++ b/scripts/ci/jobs/release.yml
@@ -9,3 +9,24 @@ release:
   release:
     tag_name: "$CI_COMMIT_TAG"
     description: "Created using the GitLab release-cli."
+
+publish wheels:
+  stage: release
+  needs: ["build wheels linux", "build wheels mac", "build wheels win"]
+  tags: [envinf, shell]
+  rules:
+    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
+      variables:
+        PYPI_PASSWORD: "${TEST_PYPI_TOKEN}"
+        PYPI_REPO: testpypi
+    - if: $CI_COMMIT_TAG
+      variables:
+        PYPI_PASSWORD: "${PYPI_TOKEN}"
+        PYPI_REPO: pypi
+  script:
+    - >
+      pipx run twine upload
+      --repository ${PYPI_REPO}
+      --username __token__
+      --password ${PYPI_PASSWORD}
+      wheelhouse/*
diff --git a/scripts/cmake/Dependencies.cmake b/scripts/cmake/Dependencies.cmake
index 5c174b73f0221bccac2669a8ad933d1864b637ee..f891daaf0e8056a5c388a858d10e85aa20606265 100644
--- a/scripts/cmake/Dependencies.cmake
+++ b/scripts/cmake/Dependencies.cmake
@@ -87,10 +87,9 @@ if(tetgen_ADDED)
     list(APPEND DISABLE_WARNINGS_TARGETS tet tetgen)
 endif()
 
-if(OGS_USE_PYTHON)
+if(OGS_USE_PYTHON OR OGS_BUILD_PYTHON_MODULE)
     CPMAddPackage(
-        NAME pybind11 GITHUB_REPOSITORY pybind/pybind11
-        GIT_TAG f1abf5d9159b805674197f6bc443592e631c9130
+        NAME pybind11 GITHUB_REPOSITORY pybind/pybind11 VERSION 2.10.0
     )
 endif()
 
@@ -182,6 +181,7 @@ if(OGS_BUILD_SWMM)
     CPMAddPackage(
         NAME SWMMInterface GITHUB_REPOSITORY ufz/SwmmInterface
         GIT_TAG 141e05ae1f419918799d7bf9178ebcd97feb1ed3
+        OPTIONS "BUILD_SHARED_LIBS OFF"
     )
     if(SWMMInterface_ADDED)
         target_include_directories(
diff --git a/scripts/cmake/DependenciesExternalProject.cmake b/scripts/cmake/DependenciesExternalProject.cmake
index ec14fe15943775282e8f8c25f56560c8154a9008..5a9c922131b06936bee06edf113726f7cf6cd4b2 100644
--- a/scripts/cmake/DependenciesExternalProject.cmake
+++ b/scripts/cmake/DependenciesExternalProject.cmake
@@ -202,7 +202,7 @@ if(OGS_USE_MPI)
     set(HDF5_PREFER_PARALLEL ON)
     list(APPEND _hdf5_options "-DHDF5_ENABLE_PARALLEL=ON")
 endif()
-if(WIN32)
+if(WIN32 OR HDF5_USE_STATIC_LIBRARIES)
     set(HDF5_USE_STATIC_LIBRARIES ON)
     list(APPEND _hdf5_options "-DBUILD_SHARED_LIBS=OFF")
 endif()
diff --git a/scripts/cmake/MetisSetup.cmake b/scripts/cmake/MetisSetup.cmake
index 6c6fde90caefe857442f3432554b4e7a8138dd0a..73131168b269d1e5bec229e4c46251aff56d0af7 100644
--- a/scripts/cmake/MetisSetup.cmake
+++ b/scripts/cmake/MetisSetup.cmake
@@ -10,7 +10,10 @@ include(${GKLIB_PATH}/GKlibSystem.cmake)
 
 # Metis library
 file(GLOB _metis_sources ${metis_SOURCE_DIR}/libmetis/*.c)
-ogs_add_library(ogs_metis ${GKlib_sources} ${_metis_sources})
+if(WIN32)
+    set(_metis_static STATIC)
+endif()
+ogs_add_library(ogs_metis ${_metis_static} ${GKlib_sources} ${_metis_sources})
 target_compile_definitions(ogs_metis PUBLIC USE_GKREGEX)
 target_include_directories(
     ogs_metis PUBLIC ${metis_SOURCE_DIR}/GKlib ${metis_SOURCE_DIR}/include
diff --git a/scripts/cmake/ProjectSetup.cmake b/scripts/cmake/ProjectSetup.cmake
index 597971cecf25a589e51e31c2598a30264da15f90..16bd5894c4da242593a50cc1ac8c8613b90ce7cc 100644
--- a/scripts/cmake/ProjectSetup.cmake
+++ b/scripts/cmake/ProjectSetup.cmake
@@ -40,8 +40,12 @@ endif()
 file(RELATIVE_PATH relDir ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR}
      ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR}
 )
-list(APPEND CMAKE_INSTALL_RPATH ${BASEPOINT} ${BASEPOINT}/${relDir})
-list(APPEND CMAKE_BUILD_RPATH ${BASEPOINT} ${BASEPOINT}/${relDir})
+list(APPEND CMAKE_INSTALL_RPATH ${BASEPOINT} ${BASEPOINT}/${relDir}
+     ${BASEPOINT}/${CMAKE_INSTALL_LIBDIR} # Python modules
+)
+list(APPEND CMAKE_BUILD_RPATH ${BASEPOINT} ${BASEPOINT}/${relDir}
+     ${BASEPOINT}/${CMAKE_INSTALL_LIBDIR} # Python modules
+)
 
 # Some external dependencies always use lib instead of lib64, Fix for
 # lib64-based systems, e.g. OpenSUSE:
diff --git a/scripts/cmake/PythonSetup.cmake b/scripts/cmake/PythonSetup.cmake
index 833bb5718ca041c4ad10a61af8e6f1312678fd97..ef6be751483de6a3bb6e6784b3888f0a98e3cccf 100644
--- a/scripts/cmake/PythonSetup.cmake
+++ b/scripts/cmake/PythonSetup.cmake
@@ -1,6 +1,9 @@
 # cmake-lint: disable=C0103
 
-set(_python_version_max "...<3.11")
+if(OGS_USE_PYTHON)
+    set(_python_version_max "...<3.11")
+endif()
+
 if(OGS_USE_PIP)
     set(Python_ROOT_DIR ${PROJECT_BINARY_DIR}/.venv)
     set(CMAKE_REQUIRE_FIND_PACKAGE_Python TRUE)
@@ -38,9 +41,15 @@ endif()
 
 set(_python_componets Interpreter)
 if(OGS_USE_PYTHON)
+    list(APPEND _python_componets Development.Embed)
+endif()
+if(OGS_BUILD_PYTHON_MODULE)
+    list(APPEND _python_componets Development.Module)
+endif()
+if(OGS_USE_PYTHON OR OGS_BUILD_PYTHON_MODULE)
     set(CMAKE_REQUIRE_FIND_PACKAGE_Python TRUE)
-    list(APPEND _python_componets Development)
 endif()
+
 find_package(
     Python ${ogs.minimum_version.python}${_python_version_max}
     COMPONENTS ${_python_componets}
diff --git a/scripts/cmake/packaging/Pack.cmake b/scripts/cmake/packaging/Pack.cmake
index 8c1daaf61bc60bc76d8c9b41e7af900ffa9f237a..33b91a33fc6cfa5426d9abf0f198168608e3e53a 100644
--- a/scripts/cmake/packaging/Pack.cmake
+++ b/scripts/cmake/packaging/Pack.cmake
@@ -76,7 +76,9 @@ if(OGS_USE_PYTHON)
         install(FILES ${PYTHON_RUNTIME_LIBS} DESTINATION bin)
         file(COPY ${PYTHON_RUNTIME_LIBS} DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
     else()
-        install(FILES ${Python_LIBRARIES} DESTINATION bin)
+        file(INSTALL ${Python_LIBRARIES} DESTINATION ${CMAKE_INSTALL_LIBDIR}
+             FOLLOW_SYMLINK_CHAIN
+        )
     endif()
 endif()
 
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..177b162c83044cd175d3252435063e330c22709a
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,69 @@
+from skbuild import setup
+from setuptools import find_packages
+
+import os
+import platform
+import re
+import subprocess
+import sys
+
+
+def get_version():
+    git_version = ""
+    if "OGS_VERSION" in os.environ:
+        git_version = os.environ["OGS_VERSION"]
+    else:
+        git_describe_cmd = ["git describe --tags"]
+        if platform.system() == "Windows":
+            git_describe_cmd = ["git", "describe", "--tags"]
+        git_version = subprocess.run(
+            git_describe_cmd,
+            capture_output=True,
+            text=True,
+            shell=True,
+        ).stdout.strip()
+
+    if re.match("\d+\.\d+\.\d+-\d+-g\w+", git_version):
+        # Make it PEP 440 compliant
+        # e.g. 6.4.2-1140-g85bbc8b4e1 -> 6.4.2.dev1140
+        m = re.match(".+?(?=-g[\w]*$)", git_version)  # strip out commit hash
+        if m:
+            return m.group(0).replace("-", ".dev")  # insert dev
+        print("ERROR: Could not get ogs version!")
+        exit(1)
+
+    return git_version
+
+
+sys.path.append(os.path.join("Applications", "Python"))
+from ogs._internal.provide_ogs_cli_tools_via_wheel import binaries_list
+
+console_scripts = []
+for b in binaries_list:
+    console_scripts.append(f"{b}=ogs._internal.provide_ogs_cli_tools_via_wheel:{b}")
+
+cmake_preset = "wheel"
+if platform.system() == "Windows":
+    cmake_preset += "-win"
+
+from pathlib import Path
+
+this_directory = Path(__file__).parent
+long_description = (this_directory / "README.md").read_text()
+
+setup(
+    name="ogs",
+    version=get_version(),
+    description="OpenGeoSys Python Module",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    author="OpenGeoSys Community",
+    license="BSD-3-Clause",
+    packages=find_packages(where="Applications/Python"),
+    package_dir={"": "Applications/Python"},
+    cmake_install_dir="Applications/Python/ogs",
+    extras_require={"test": ["pytest"]},
+    cmake_args=[f"--preset {cmake_preset}", "-B ."],
+    python_requires=">=3.7",
+    entry_points={"console_scripts": console_scripts},
+)
diff --git a/web/content/docs/devguide/advanced/python-wheel/index.md b/web/content/docs/devguide/advanced/python-wheel/index.md
new file mode 100644
index 0000000000000000000000000000000000000000..91154b43ab4eebe980e9504742840695fcf1eb90
--- /dev/null
+++ b/web/content/docs/devguide/advanced/python-wheel/index.md
@@ -0,0 +1,75 @@
++++
+date = "2022-02-09T11:00:13+01:00"
+title = "Python wheel development"
+author = "Lars Bilke"
+weight = 1068
+
+[menu]
+  [menu.devguide]
+    parent = "advanced"
++++
+
+## Local setup
+
+Python wheel builds are driven by [scikit-build](https://scikit-build.readthedocs.io/en/latest/) which basically is a `setuptools`-wrapper for CMake-based projects.
+
+The entrypoint is `setup.py` in the root directory. It uses the `wheel` CMake preset (or `wheel-win` on Windows).
+
+You can locally develop and test with the following setup:
+
+```bash
+# Create a virtual environment inside your source directory
+python3 -m venv .venv
+# Activate the environment
+source .venv/bin/activate
+# Install (build) the local Python project
+pip install -v .[test]
+...
+Successfully installed ogs-6.4.2.dev1207
+```
+
+The `pip install`-step starts a new CMake-based ogs build in `_skbuild`-subdirectory (inside the source code) using the `wheel`-preset. When the CMake build is done it installs the wheel into the activated virtual environment and you can interact with it, e.g.:
+
+```bash
+# Run python tests
+pytest
+============================================== test session starts ===============================================
+platform darwin -- Python 3.10.6, pytest-7.1.3, pluggy-1.0.0
+rootdir: ~/code/ogs/ogs, configfile: pyproject.toml, testpaths: Tests
+collected 2 items
+
+Tests/Python/test_cli.py .                                                                                 [ 50%]
+Tests/Python/test_simlator.py .                                                                            [100%]
+
+=============================================== 2 passed in 0.55s ================================================
+
+# Start the python interpreter
+python3
+>>> import ogs.simulator as sim
+>>> sim.initialize(["", "--help"])
+```
+
+If you make modifications you need to run `pip install .[test]` again (or for temporary modifications you can directly edit inside the virtual environment, e.g. in `.venv/lib/python3.10/site-packages/ogs`).
+
+The contents of `_skbuild/[platform-specific]/cmake-install` will make up the wheel.
+
+## CI
+
+For generating the various wheels for different Python versions and platforms [`cibuildwheel`](https://cibuildwheel.readthedocs.io/en/stable/) is used.
+
+You can test it locally with, e.g. only building for Python 3.10:
+
+```bash
+CIBW_BUILD="cp310*" pipx run cibuildwheel
+```
+
+Please note that on Linux `cibuildwheel` runs the builds inside [manylinux](https://github.com/pypa/manylinux) Docker containers. On other platforms the build happens with native tools. See the [cibuildwheel docs](https://cibuildwheel.readthedocs.io/en/stable/#how-it-works) for more information.
+
+Wheels are generated in the `wheelhouse/`-folder.
+
+`cibuildwheel` is configured in `pyproject.toml`:
+
+```toml
+[tool.cibuildwheel]
+...
+```
diff --git a/web/content/docs/userguide/basics/introduction/index.md b/web/content/docs/userguide/basics/introduction/index.md
index 208d6437359094e119734db3eb6ebfa13b6579a3..d808b4c07ceaabc01741f478944d5af863ccaf29 100644
--- a/web/content/docs/userguide/basics/introduction/index.md
+++ b/web/content/docs/userguide/basics/introduction/index.md
@@ -15,14 +15,60 @@ weight = 1
 post = "Download, install and run an OGS benchmark in 5 minutes! No development setup required."
 +++
 
-## Download
+## Installation
 
-Download the latest release of OpenGeoSys from the [Releases](/releases)-page. Be sure to pick the correct file for your operating system.
+<div class='win'>
 
-## Installation
+Download the latest release of OpenGeoSys from the [Releases](/releases)-page. Be sure to pick the correct file for your operating system.
 
 OGS itself is a simple executable file so you can put it anywhere you like. For convenience you may put into a location which is in your `PATH`-environment variable which allows you to start the executable without specifying its full file path.
 
+<div class="note">
+
+### Alternative: Install via `pip`
+
+You can also install ogs via Python's [`pip`-tool](https://packaging.python.org/en/latest/tutorials/installing-packages/):
+
+```bat
+pip install ogs
+```
+
+If you install into an activated [virtual environment](https://docs.python.org/3/library/venv.html) then ogs and its tools are automatically also in the `PATH`. Otherwise `pip` will print instructions which directory needs to be added to the `PATH`.
+
+</div>
+
+</div>
+
+<div class='linux'>
+
+Install via Python's [`pip`-tool](https://packaging.python.org/en/latest/tutorials/installing-packages/):
+
+```bash
+pip install ogs
+```
+
+You may want to set up and activate a [virtual environment](https://docs.python.org/3/library/venv.html) before.
+
+You could also use [`pipx`](https://pypa.github.io/pipx/) to install into an isolated environment.
+
+</div>
+
+<div class='mac'>
+
+See Linux tab!
+
+</div>
+
+<div class="note">
+
+### Limitations of the `pip`-based installation
+
+- Serial config only! For PETSc-support please use a [Singularity container]({{< relref "container" >}}).
+- No embedded Python interpreter, i.e. no Python boundary conditions!
+- A Python (3.8 - 3.11) installation with `pip` is required.
+
+</div>
+
 ## Download benchmarks
 
 You can download the latest benchmark files from GitLab:
diff --git a/web/data/versions.json b/web/data/versions.json
index d064e9cdf8d849806bc839b80f42998d37cbda56..45f0d34dee4b6ccbbfe505ceaa5b61985d6402a9 100644
--- a/web/data/versions.json
+++ b/web/data/versions.json
@@ -71,8 +71,8 @@
     }
   },
   "cpm": {
-    "package_file_id": 119,
-    "package_file_sha256": "7d98b148e6d24acd72d17d2503f3ce3bab74029da5b3328788b2d4c379e9dcac"
+    "package_file_id": 182,
+    "package_file_sha256": "00d7dea24754ad415e7003535b36a7d5b4e7224701341f5ca587f93e42b63563"
   },
   "ext": {
     "cache_hash": "e6f3f1f4c29c6c5f096f89785e6e245bdf39ac1a"