/* === This file is part of Calamares - <https://calamares.io> === * * SPDX-FileCopyrightText: 2023 Adriaan de Groot <groot@kde.org> * SPDX-License-Identifier: GPL-3.0-or-later * * Calamares is Free Software: see the License-Identifier above. * */ #include "PythonJob.h" #include "CalamaresVersionX.h" #include "GlobalStorage.h" #include "JobQueue.h" #include "pybind11/Api.h" #include "pybind11/Pybind11Helpers.h" #include "python/Api.h" #include "utils/Logger.h" #include <QDir> #include <QFileInfo> #include <QString> #ifdef WITH_PYBIND11 #else #error Source only for pybind11 #endif namespace py = pybind11; // Forward-declare function generated by PYBIND11_MODULE static void pybind11_init_libcalamares( ::pybind11::module_& variable ); namespace { static const char* s_preScript = nullptr; QString getPrettyNameFromScope( const py::dict& scope ) { static constexpr char key_name[] = "pretty_name"; if ( scope.contains( key_name ) ) { const py::object func = scope[ key_name ]; try { const auto s = func().cast< std::string >(); return QString::fromUtf8( s.c_str() ); } catch ( const py::cast_error& ) { // Ignore, we will try __doc__ next } } static constexpr char key_doc[] = "__doc__"; if ( scope.contains( key_doc ) ) { const py::object doc = scope[ key_doc ]; try { const auto s = doc.cast< std::string >(); auto string = QString::fromUtf8( s.c_str() ).trimmed(); const auto newline_index = string.indexOf( '\n' ); if ( newline_index >= 0 ) { string.truncate( newline_index ); return string; } // __doc__ is apparently empty, try next fallback } catch ( const py::cast_error& ) { // Ignore, try next fallback } } // No more fallbacks return QString(); } void populate_utils( py::module_& m ) { m.def( "obscure", &Calamares::Python::obscure, "A function that obscures (encodes) a string" ); m.def( "debug", &Calamares::Python::debug, "Log a debug-message" ); m.def( "warn", &Calamares::Python::warning, "Log a warning-message" ); m.def( "warning", &Calamares::Python::warning, "Log a warning-message" ); m.def( "error", &Calamares::Python::error, "Log an error-message" ); m.def( "load_yaml", &Calamares::Python::load_yaml, "Loads YAML from a file." ); m.def( "target_env_call", py::overload_cast<const Calamares::Python::List& , const std::string& , int >(&Calamares::Python::target_env_call), "Runs command_list in target, returns exit code.", py::arg( "command_list" ), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "target_env_call", py::overload_cast<const std::string& , const std::string& , int >(&Calamares::Python::target_env_call), "Runs command in target, returns exit code.", py::arg( "command_list" ), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "check_target_env_call", &Calamares::Python::check_target_env_call, "Runs command in target, raises on error exit.", py::arg( "command_list" ), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "check_target_env_output", &Calamares::Python::check_target_env_output, "Runs command in target, returns standard output or raises on error.", py::arg( "command_list" ), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "target_env_process_output", &Calamares::Python::target_env_process_output, "Runs command in target, updating callback and returns standard output or raises on error.", py::arg( "command_list" ), py::arg( "callback" ) = pybind11::none(), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "host_env_process_output", &Calamares::Python::host_env_process_output, "Runs command in target, updating callback and returns standard output or raises on error.", py::arg( "command_list" ), py::arg( "callback" ) = pybind11::none(), py::arg( "input" ) = std::string(), py::arg( "timeout" ) = 0 ); m.def( "gettext_languages", &Calamares::Python::gettext_languages, "Returns list of languages (most to least-specific) for gettext." ); m.def( "gettext_path", &Calamares::Python::gettext_path, "Returns path for gettext search." ); m.def( "mount", &Calamares::Python::mount, "Runs the mount utility with the specified parameters.\n" "Returns the program's exit code, or:\n" "-1 = QProcess crash\n" "-2 = QProcess cannot start\n" "-3 = bad arguments" ); } void populate_libcalamares( py::module_& m ) { m.doc() = "Calamares API for Python"; m.add_object( "ORGANIZATION_NAME", Calamares::Python::String( CALAMARES_ORGANIZATION_NAME ) ); m.add_object( "ORGANIZATION_DOMAIN", Calamares::Python::String( CALAMARES_ORGANIZATION_DOMAIN ) ); m.add_object( "APPLICATION_NAME", Calamares::Python::String( CALAMARES_APPLICATION_NAME ) ); m.add_object( "VERSION", Calamares::Python::String( CALAMARES_VERSION ) ); m.add_object( "VERSION_SHORT", Calamares::Python::String( CALAMARES_VERSION_SHORT ) ); auto utils = m.def_submodule( "utils", "Calamares Utility API for Python" ); populate_utils( utils ); py::class_< Calamares::Python::JobProxy >( m, "Job" ) .def_readonly( "module_name", &Calamares::Python::JobProxy::moduleName ) .def_readonly( "pretty_name", &Calamares::Python::JobProxy::prettyName ) .def_readonly( "working_path", &Calamares::Python::JobProxy::workingPath ) .def_readonly( "configuration", &Calamares::Python::JobProxy::configuration ) .def( "setprogress", &Calamares::Python::JobProxy::setprogress ); py::class_< Calamares::Python::GlobalStorageProxy >( m, "GlobalStorage" ) .def( py::init( []( std::nullptr_t ) { return new Calamares::Python::GlobalStorageProxy( nullptr ); } ) ) .def( "contains", &Calamares::Python::GlobalStorageProxy::contains ) .def( "count", &Calamares::Python::GlobalStorageProxy::count ) .def( "insert", &Calamares::Python::GlobalStorageProxy::insert ) .def( "keys", &Calamares::Python::GlobalStorageProxy::keys ) .def( "remove", &Calamares::Python::GlobalStorageProxy::remove ) .def( "value", &Calamares::Python::GlobalStorageProxy::value ); } } // namespace namespace Calamares { namespace Python { struct Job::Private { Private( const QString& script, const QString& path, const QVariantMap& configuration ) : scriptFile( script ) , workingPath( path ) , configurationMap( configuration ) { } QString scriptFile; // From the module descriptor QString workingPath; QVariantMap configurationMap; // The module configuration QString description; // Obtained from the Python code }; Job::Job( const QString& scriptFile, const QString& workingPath, const QVariantMap& moduleConfiguration, QObject* parent ) : ::Calamares::Job( parent ) , m_d( std::make_unique< Job::Private >( scriptFile, workingPath, moduleConfiguration ) ) { } Job::~Job() {} QString Job::prettyName() const { return QDir( m_d->workingPath ).dirName(); } QString Job::prettyStatusMessage() const { // The description is updated when progress is reported, see emitProgress() if ( m_d->description.isEmpty() ) { return tr( "Running %1 operation." ).arg( prettyName() ); } else { return m_d->description; } } JobResult Job::exec() { // We assume m_scriptFile to be relative to m_workingPath. QDir workingDir( m_d->workingPath ); if ( !workingDir.exists() || !workingDir.isReadable() ) { return JobResult::error( tr( "Bad working directory path" ), tr( "Working directory %1 for python job %2 is not readable." ) .arg( m_d->workingPath ) .arg( prettyName() ) ); } QFileInfo scriptFI( workingDir.absoluteFilePath( m_d->scriptFile ) ); if ( !scriptFI.exists() || !scriptFI.isFile() || !scriptFI.isReadable() ) { return JobResult::error( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 is not readable." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ) ); } py::scoped_interpreter guard {}; // Import, but do not keep the handle lying around try { // import() only works if the library can be found through // normal Python import mechanisms -- and after installation, // libcalamares can not be found. An alternative, like using // PYBIND11_EMBEDDED_MODULE, falls foul of not being able // to `import libcalamares` from external Python scripts, // which are used in tests. // // auto calamaresModule = py::module_::import( "libcalamares" ); // // Using the constructor directly generates compiler warnings // because this is deprecated. // // auto calamaresModule = py::module_("libcalamares"); // // So create it by hand, using code cribbed from pybind11/embed.h // to register an extension module. This does not make it // available to the current interpreter. // static ::pybind11::module_::module_def libcalamares_def; auto calamaresModule = py::module_::create_extension_module( "libcalamares", nullptr, &libcalamares_def ); pybind11_init_libcalamares( calamaresModule ); // Add libcalamares to the main namespace (as if it has already // been imported) and also to sys.modules under its own name. // Now `import libcalamares` in modules will find the already- // loaded module. auto scope = py::module_::import( "__main__" ).attr( "__dict__" ); scope[ "libcalamares" ] = calamaresModule; auto sys = scope[ "sys" ].attr( "modules" ); sys[ "libcalamares" ] = calamaresModule; calamaresModule.attr( "job" ) = Calamares::Python::JobProxy( this ); calamaresModule.attr( "globalstorage" ) = Calamares::Python::GlobalStorageProxy( JobQueue::instance()->globalStorage() ); } catch ( const py::error_already_set& e ) { cError() << "Error in import:" << e.what(); throw; // This is non-recoverable } if ( s_preScript ) { try { py::exec( s_preScript ); } catch ( const py::error_already_set& e ) { cError() << "Error in pre-script:" << e.what(); return JobResult::internalError( tr( "Bad internal script" ), tr( "Internal script for python job %1 raised an exception." ).arg( prettyName() ), JobResult::PythonUncaughtException ); } } try { py::eval_file( scriptFI.absoluteFilePath().toUtf8().constData() ); } catch ( const py::error_already_set& e ) { cError() << "Error while loading:" << e.what(); return JobResult::internalError( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 could not be loaded because it raised an exception." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ), JobResult::PythonUncaughtException ); } auto scope = py::module_::import( "__main__" ).attr( "__dict__" ); m_d->description = getPrettyNameFromScope( scope ); Q_EMIT progress( 0 ); static constexpr char key_run[] = "run"; if ( scope.contains( key_run ) ) { const py::object run = scope[ key_run ]; try { py::object r; try { r = run(); } catch ( const py::error_already_set& e ) { // This is an error in the Python code itself cError() << "Error while running:" << e.what(); return JobResult::internalError( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 raised an exception." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ), JobResult::PythonUncaughtException ); } if ( r.is( py::none() ) ) { return JobResult::ok(); } const py::tuple items = r; return JobResult::error( asQString( items[ 0 ] ), asQString( items[ 1 ] ) ); } catch ( const py::cast_error& e ) { cError() << "Error in type of run() or its results:" << e.what(); return JobResult::error( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 returned invalid results." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ) ); } catch ( const py::error_already_set& e ) { cError() << "Error in return type of run():" << e.what(); return JobResult::error( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 returned invalid results." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ) ); } } else { return JobResult::error( tr( "Bad main script file" ), tr( "Main script file %1 for python job %2 does not contain a run() function." ) .arg( scriptFI.absoluteFilePath() ) .arg( prettyName() ) ); } } QString Job::workingPath() const { return m_d->workingPath; } QVariantMap Job::configuration() const { return m_d->configurationMap; } void Job::emitProgress( double progressValue ) { // TODO: update prettyname emit progress( progressValue ); } /** @brief Sets the pre-run Python code for all PythonJobs * * A PythonJob runs the code from the scriptFile parameter to * the constructor; the pre-run code is **also** run, before * even the scriptFile code. Use this in testing mode * to modify Python internals. * * No ownership of @p script is taken: pass in a pointer to * a character literal or something that lives longer than the * job. Pass in @c nullptr to switch off pre-run code. */ void Job::setInjectedPreScript( const char* script ) { s_preScript = script; cDebug() << "Python pre-script set to string" << Logger::Pointer( script ) << "length" << ( script ? strlen( script ) : 0 ); } } // namespace Python } // namespace Calamares PYBIND11_MODULE( libcalamares, m ) { populate_libcalamares( m ); }