From 0dcbac7b305d933b82a249fc18a4b6152567aa4c Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 12 Sep 2025 15:25:47 -0500 Subject: [PATCH 1/4] App: Add Python interface to ApplicationDirectories The Python version of this class is entirely static: no instance is required. Internally it accesses th pre-constructed instance of the C++ ApplicationDirectories class that is instantiated at program startup by the Application class. --- src/App/Application.cpp | 2 + src/App/ApplicationDirectories.pyi | 108 +++++++++++++++++ src/App/ApplicationDirectoriesPyImp.cpp | 154 ++++++++++++++++++++++++ src/App/CMakeLists.txt | 3 + 4 files changed, 267 insertions(+) create mode 100644 src/App/ApplicationDirectories.pyi create mode 100644 src/App/ApplicationDirectoriesPyImp.cpp diff --git a/src/App/Application.cpp b/src/App/Application.cpp index a5dd147c00..290f2099a9 100644 --- a/src/App/Application.cpp +++ b/src/App/Application.cpp @@ -103,6 +103,7 @@ #include "Annotation.h" #include "Application.h" #include "ApplicationDirectories.h" +#include "ApplicationDirectoriesPy.h" #include "CleanupProcess.h" #include "ComplexGeoData.h" #include "Services.h" @@ -333,6 +334,7 @@ void Application::setupPythonTypes() Base::InterpreterSingleton::addType(&Base::TypePy ::Type,pBaseModule,"TypeId"); Base::InterpreterSingleton::addType(&Base::PrecisionPy ::Type,pBaseModule,"Precision"); + Base::InterpreterSingleton::addType(&ApplicationDirectoriesPy::Type, pAppModule, "ApplicationDirectories"); Base::InterpreterSingleton::addType(&MaterialPy::Type, pAppModule, "Material"); Base::InterpreterSingleton::addType(&MetadataPy::Type, pAppModule, "Metadata"); diff --git a/src/App/ApplicationDirectories.pyi b/src/App/ApplicationDirectories.pyi new file mode 100644 index 0000000000..ee3419eda8 --- /dev/null +++ b/src/App/ApplicationDirectories.pyi @@ -0,0 +1,108 @@ +from Base.PyObjectBase import PyObjectBase +from typing import List + + +class ApplicationDirectories(PyObjectBase): + """ + App.ApplicationDirectories class. + + For the time being this class only provides access to the directory versioning methods of its + C++ counterpart. These are all static methods, so no instance is needed. The main methods of + this class are migrateAllPaths(), usingCurrentVersionConfig(), and versionStringForPath(). + + Author: Chris Hennes (chennes@pioneerlibrarysystem.org) + Licence: LGPL-2.1-or-later + DeveloperDocu: ApplicationDirectories + """ + + @staticmethod + def usingCurrentVersionConfig(path:str) -> bool: + """ + usingCurrentVersionConfig(path) + + Determine if a given config path is for the current version of the program + + path : the path to check + """ + ... + + @staticmethod + def migrateAllPaths(paths: List[str]) -> None: + """ + migrateAllPaths(paths) + + Migrate a set of versionable configuration directories from the given paths to a new + version. The new version's directories cannot exist yet, and the old ones *must* exist. + If the old paths are themselves versioned, then the new paths will be placed at the same + level in the directory structure (e.g., they will be siblings of each entry in paths). + If paths are NOT versioned, the new (versioned) copies will be placed *inside* the + original paths. + + If the list contains the same path multiple times, the duplicates are ignored, so it is safe + to pass the same path multiple times. + + Examples: + Running FreeCAD 1.1, /usr/share/FreeCAD/Config/ -> /usr/share/FreeCAD/Config/v1-1/ + Running FreeCAD 1.1, /usr/share/FreeCAD/Config/v1-1 -> raises exception, path exists + Running FreeCAD 1.2, /usr/share/FreeCAD/Config/v1-1/ -> /usr/share/FreeCAD/Config/v1-2/ + """ + ... + + @staticmethod + def versionStringForPath(major:int, minor:int) -> str: + """ + versionStringForPath(major, minor) -> str + + Given a major and minor version number, return a string that can be used as the name for a + versioned subdirectory. Only returns the version string, not the full path. + """ + ... + + @staticmethod + def isVersionedPath(startingPath:str) -> bool: + """ + isVersionedPath(startingPath) -> bool + + Determine if a given path is versioned (that is, if its last component contains + something that this class would have created as a versioned subdirectory). Returns true + for any path that the *current* version of FreeCAD would recognized as versioned, and false + for either something that is not versioned, or something that is versioned but for a later + version of FreeCAD. + """ + ... + + @staticmethod + def mostRecentAvailableConfigVersion(startingPath:str) -> str: + """ + mostRecentAvailableConfigVersion(startingPath) -> str + + Given a base path that is expected to contain versioned subdirectories, locate the + directory name (*not* the path, only the final component, the version string itself) + corresponding to the most recent version of the software, up to and including the current + running version, but NOT exceeding it -- any *later* version whose directories exist + in the path is ignored. See also mostRecentConfigFromBase(). + """ + ... + + @staticmethod + def mostRecentConfigFromBase(startingPath: str) -> str: + """ + mostRecentConfigFromBase(startingPath) -> str + + Given a base path that is expected to contained versioned subdirectories, locate the + directory corresponding to the most recent version of the software, up to and including + the current version, but NOT exceeding it. Returns the complete path, not just the final + component. See also mostRecentAvailableConfigVersion(). + """ + ... + + @staticmethod + def migrateConfig(oldPath: str, newPath: str) -> None: + """ + migrateConfig(oldPath, newPath) -> None + + A utility method to copy all files and directories from oldPath to newPath, handling the + case where newPath might itself be a subdirectory of oldPath (and *not* attempting that + otherwise-recursive copy). + """ + ... diff --git a/src/App/ApplicationDirectoriesPyImp.cpp b/src/App/ApplicationDirectoriesPyImp.cpp new file mode 100644 index 0000000000..2b1b0e8a48 --- /dev/null +++ b/src/App/ApplicationDirectoriesPyImp.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/*************************************************************************************************** + * * + * Copyright (c) 2025 The FreeCAD project association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it under the terms of the * + * GNU Lesser General Public License as published by the Free Software Foundation, either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public License along with FreeCAD. * + * If not, see . * + * * + **************************************************************************************************/ + +#include "PreCompiled.h" // NOLINT +#ifndef _PreComp_ +#include +#include +#include +#include +#endif + + +#include "Application.h" + +// inclusion of the generated files (generated out of ApplicationDirectories.pyi) +#include +#include // NOLINT + +namespace fs = std::filesystem; + +using namespace App; + +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) + +// returns a string which represent the object e.g. when printed in python +std::string ApplicationDirectoriesPy::representation() const +{ + return {""}; +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::usingCurrentVersionConfig(PyObject* args) +{ + char *path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + bool result = App::Application::directories()->usingCurrentVersionConfig(Base::FileInfo::stringToPath(path)); + return Py::new_reference_to(Py::Boolean(result)); +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::migrateAllPaths(PyObject* args) +{ + PyObject* object {nullptr}; + if (!PyArg_ParseTuple(args, "O", &object)) { + return nullptr; + } + + if (PyTuple_Check(object) || PyList_Check(object)) { + Py::Sequence seq(object); + Py::Sequence::size_type size = seq.size(); + std::vector paths; + paths.resize(size); + + for (Py::Sequence::size_type i = 0; i < size; i++) { + Py::Object item = seq[i]; + if (PyUnicode_Check(item.ptr())) { + const char* s = PyUnicode_AsUTF8(item.ptr()); + if (!s) { + return nullptr; // PyUnicode_AsUTF8 sets an error + } + paths[i] = Base::FileInfo::stringToPath(s); + } + else { + PyErr_SetString(PyExc_RuntimeError, "path was not a string"); + return nullptr; + } + } + App::Application::directories()->migrateAllPaths(paths); + } + Py_Return; +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::versionStringForPath(PyObject* args) +{ + int major {0}; + int minor {0}; + if (!PyArg_ParseTuple(args, "ii", &major, &minor)) { + return nullptr; + } + auto result = App::ApplicationDirectories::versionStringForPath(major, minor); + return Py::new_reference_to(Py::String(result)); +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::isVersionedPath(PyObject* args) +{ + char *path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + App::Application::directories()->isVersionedPath(Base::FileInfo::stringToPath(path)); + Py_Return; +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::mostRecentAvailableConfigVersion(PyObject* args) +{ + char *path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + std::string result = App::Application::directories()->mostRecentAvailableConfigVersion(Base::FileInfo::stringToPath(path)); + return Py::new_reference_to(Py::String(result)); +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::mostRecentConfigFromBase(PyObject* args) +{ + char *path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + fs::path result = App::Application::directories()->mostRecentConfigFromBase(Base::FileInfo::stringToPath(path)); + return Py::new_reference_to(Py::String(Base::FileInfo::pathToString(result))); +} + +[[maybe_unused]] PyObject* ApplicationDirectoriesPy::migrateConfig(PyObject* args) +{ + char *oldPath = nullptr; + char *newPath = nullptr; + if (!PyArg_ParseTuple(args, "ss", &oldPath, &newPath)) { + return nullptr; + } + App::ApplicationDirectories::migrateConfig( + Base::FileInfo::stringToPath(oldPath), + Base::FileInfo::stringToPath(newPath)); + Py_Return; +} + +PyObject* ApplicationDirectoriesPy::getCustomAttributes([[maybe_unused]] const char* attr) const +{ + return nullptr; +} + +int ApplicationDirectoriesPy::setCustomAttributes([[maybe_unused]] const char* attr, [[maybe_unused]] PyObject* obj) +{ + return 0; +} + +// NOLINTEND(cppcoreguidelines-pro-type-vararg) diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt index 97e372b07e..9035ddd607 100644 --- a/src/App/CMakeLists.txt +++ b/src/App/CMakeLists.txt @@ -86,6 +86,7 @@ list(APPEND FreeCADApp_LIBS ${QtXml_LIBRARIES} ) +generate_from_py(ApplicationDirectories) generate_from_py(Document) generate_from_py(DocumentObject) generate_from_py(Extension) @@ -112,6 +113,7 @@ generate_embed_from_py(FreeCADInit InitScript.h) generate_embed_from_py(FreeCADTest TestScript.h) SET(FreeCADApp_Pyi_SRCS + ApplicationDirectories.pyi Extension.pyi ExtensionContainer.pyi DocumentObjectExtension.pyi @@ -281,6 +283,7 @@ SET(FreeCADApp_CPP_SRCS ${Properties_CPP_SRCS} Application.cpp ApplicationDirectories.cpp + ApplicationDirectoriesPyImp.cpp ApplicationPy.cpp AutoTransaction.cpp Branding.cpp From b64b0f528bf0b0941c66cb912f97d9cbc322cd04 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 17 Sep 2025 16:08:07 -0500 Subject: [PATCH 2/4] App: Address reviewer comments --- src/App/ApplicationDirectories.cpp | 2 +- src/App/ApplicationDirectoriesPyImp.cpp | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp index 22b357be47..ebdea14b7c 100644 --- a/src/App/ApplicationDirectories.cpp +++ b/src/App/ApplicationDirectories.cpp @@ -569,7 +569,7 @@ void ApplicationDirectories::migrateAllPaths(const std::vector &paths) newPath = path / versionStringForPath(major, minor); } if (fs::exists(newPath)) { - throw Base::RuntimeError("Cannot migrate config - path already exists: " + Base::FileInfo::pathToString(newPath)); + continue; // Ignore an existing path: not an error, just a migration that was already done } fs::create_directories(newPath); migrateConfig(path, newPath); diff --git a/src/App/ApplicationDirectoriesPyImp.cpp b/src/App/ApplicationDirectoriesPyImp.cpp index 2b1b0e8a48..6920f79311 100644 --- a/src/App/ApplicationDirectoriesPyImp.cpp +++ b/src/App/ApplicationDirectoriesPyImp.cpp @@ -70,17 +70,16 @@ std::string ApplicationDirectoriesPy::representation() const for (Py::Sequence::size_type i = 0; i < size; i++) { Py::Object item = seq[i]; - if (PyUnicode_Check(item.ptr())) { - const char* s = PyUnicode_AsUTF8(item.ptr()); - if (!s) { - return nullptr; // PyUnicode_AsUTF8 sets an error - } - paths[i] = Base::FileInfo::stringToPath(s); - } - else { + + if (!PyUnicode_Check(item.ptr())) { PyErr_SetString(PyExc_RuntimeError, "path was not a string"); return nullptr; } + const char* s = PyUnicode_AsUTF8(item.ptr()); + if (!s) { + return nullptr; // PyUnicode_AsUTF8 sets an error + } + paths[i] = Base::FileInfo::stringToPath(s); } App::Application::directories()->migrateAllPaths(paths); } From a6c5e9f08e990cc8b9f5b7594f1a8c076116471f Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 17 Sep 2025 16:18:10 -0500 Subject: [PATCH 3/4] App: Remove manual PCH entries --- src/App/ApplicationDirectories.cpp | 3 --- src/App/ApplicationDirectoriesPyImp.cpp | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp index ebdea14b7c..e7e07bf690 100644 --- a/src/App/ApplicationDirectories.cpp +++ b/src/App/ApplicationDirectories.cpp @@ -19,15 +19,12 @@ * * **************************************************************************************************/ -#include "PreCompiled.h" -#ifndef _PreComp_ #include #include #include #include #include #include -#endif #include "ApplicationDirectories.h" diff --git a/src/App/ApplicationDirectoriesPyImp.cpp b/src/App/ApplicationDirectoriesPyImp.cpp index 6920f79311..da84f678b7 100644 --- a/src/App/ApplicationDirectoriesPyImp.cpp +++ b/src/App/ApplicationDirectoriesPyImp.cpp @@ -18,13 +18,10 @@ * * **************************************************************************************************/ -#include "PreCompiled.h" // NOLINT -#ifndef _PreComp_ #include #include #include #include -#endif #include "Application.h" From 8f128f21f9da67e387a8d677cac6dd359563acb6 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 17 Sep 2025 17:02:35 -0500 Subject: [PATCH 4/4] Tests: Update to match new behavior --- tests/src/App/ApplicationDirectories.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/src/App/ApplicationDirectories.cpp b/tests/src/App/ApplicationDirectories.cpp index 493c1348e1..9c804e7698 100644 --- a/tests/src/App/ApplicationDirectories.cpp +++ b/tests/src/App/ApplicationDirectories.cpp @@ -552,16 +552,18 @@ TEST_F(ApplicationDirectoriesTest, migrateAllPathsNonVersionedInputAppendsCurren } // Pre-existing destination -> throws Base::RuntimeError -TEST_F(ApplicationDirectoriesTest, migrateAllPathsThrowsIfDestinationAlreadyExists_NonVersioned) +TEST_F(ApplicationDirectoriesTest, migrateAllPathsIgnoresIfDestinationAlreadyExists_NonVersioned) { auto appDirs = makeAppDirsForVersion(5, 4); fs::path base = tempDir() / "exists_case"; fs::create_directories(base); - fs::create_directories(versionedPath(base, 5, 4)); // destination already exists + fs::path dest = versionedPath(base, 5, 4); + fs::create_directories(dest); // destination already exists std::vector inputs {base}; - EXPECT_THROW(appDirs->migrateAllPaths(inputs), Base::RuntimeError); + + ASSERT_NO_THROW(appDirs->migrateAllPaths(inputs)); } // Multiple inputs: one versioned, one non-versioned -> both destinations created