Add a new plugin system for the new API

This commit is contained in:
Jon Evans 2024-01-13 23:14:02 -05:00
parent f613cd1cb4
commit a3b6ab48a4
25 changed files with 1256 additions and 50 deletions

View File

@ -145,6 +145,7 @@ set( KICOMMON_SRCS
env_vars.cpp
exceptions.cpp
gestfich.cpp
json_conversions.cpp
kiid.cpp
kiway.cpp
kiway_express.cpp
@ -178,6 +179,16 @@ set( KICOMMON_SRCS
io/kicad/kicad_io_utils.cpp # needed by richio
)
if( KICAD_IPC_API )
set( KICOMMON_SRCS
${KICOMMON_SRCS}
api/api_plugin.cpp
api/api_plugin_manager.cpp
../scripting/python_manager.cpp
)
endif()
add_library( kicommon SHARED
${KICOMMON_SRCS}
)
@ -254,6 +265,7 @@ target_include_directories( kicommon
PUBLIC
.
${CMAKE_BINARY_DIR}
$<TARGET_PROPERTY:magic_enum,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:pegtl,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:kiapi,INTERFACE_INCLUDE_DIRECTORIES>
)

331
common/api/api_plugin.cpp Normal file
View File

@ -0,0 +1,331 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 Jon Evans <jon@craftyjon.com>
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <magic_enum.hpp>
#include <nlohmann/json.hpp>
#include <wx/log.h>
#include <wx/regex.h>
#include <wx/stdstream.h>
#include <wx/wfstream.h>
#include <api/api_plugin.h>
#include <api/api_plugin_manager.h>
#include <json_conversions.h>
bool PLUGIN_RUNTIME::FromJson( const nlohmann::json& aJson )
{
// TODO move to tl::expected and give user feedback about parse errors
try
{
type = magic_enum::enum_cast<PLUGIN_RUNTIME_TYPE>( aJson.at( "type" ).get<std::string>(),
magic_enum::case_insensitive )
.value_or( PLUGIN_RUNTIME_TYPE::INVALID );
}
catch( ... )
{
return false;
}
return type != PLUGIN_RUNTIME_TYPE::INVALID;
}
struct API_PLUGIN_CONFIG
{
API_PLUGIN_CONFIG( API_PLUGIN& aParent, const wxFileName& aConfigFile );
bool valid;
wxString identifier;
wxString name;
wxString description;
PLUGIN_RUNTIME runtime;
std::vector<PLUGIN_ACTION> actions;
API_PLUGIN& parent;
};
API_PLUGIN_CONFIG::API_PLUGIN_CONFIG( API_PLUGIN& aParent, const wxFileName& aConfigFile ) :
parent( aParent )
{
valid = false;
if( !aConfigFile.IsFileReadable() )
return;
wxLogTrace( traceApi, "Plugin: parsing config file" );
wxFFileInputStream fp( aConfigFile.GetFullPath(), wxT( "rt" ) );
wxStdInputStream fstream( fp );
nlohmann::json js;
try
{
js = nlohmann::json::parse( fstream, nullptr,
/* allow_exceptions = */ true,
/* ignore_comments = */ true );
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception during parse" );
return;
}
// TODO add schema and validate
// All of these are required; any exceptions here leave us with valid == false
try
{
identifier = js.at( "identifier" ).get<wxString>();
name = js.at( "name" ).get<wxString>();
description = js.at( "description" ).get<wxString>();
if( !runtime.FromJson( js.at( "runtime" ) ) )
{
wxLogTrace( traceApi, "Plugin: error parsing runtime section" );
return;
}
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception while parsing required keys" );
return;
}
// At minimum, we need a reverse-DNS style identifier with two dots and a 2+ character TLD
wxRegEx identifierRegex( wxS( "[\\w\\d]{2,}\\.[\\w\\d]+\\.[\\w\\d]+" ) );
if( !identifierRegex.Matches( identifier ) )
{
wxLogTrace( traceApi, wxString::Format( "Plugin: identifier %s does not meet requirements",
identifier ) );
return;
}
wxLogTrace( traceApi, wxString::Format( "Plugin: %s (%s)", identifier, name ) );
try
{
const nlohmann::json& actionsJs = js.at( "actions" );
if( actionsJs.is_array() )
{
for( const nlohmann::json& actionJs : actionsJs )
{
if( std::optional<PLUGIN_ACTION> a = parent.createActionFromJson( actionJs ) )
{
a->identifier = wxString::Format( "%s.%s", identifier, a->identifier );
wxLogTrace( traceApi, wxString::Format( "Plugin: loaded action %s",
a->identifier ) );
actions.emplace_back( *a );
}
}
}
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception while parsing actions" );
}
valid = true;
}
API_PLUGIN::API_PLUGIN( const wxFileName& aConfigFile ) :
m_configFile( aConfigFile ),
m_config( std::make_unique<API_PLUGIN_CONFIG>( *this, aConfigFile ) )
{
}
API_PLUGIN::~API_PLUGIN()
{
}
bool API_PLUGIN::IsOk() const
{
return m_config->valid;
}
const wxString& API_PLUGIN::Identifier() const
{
return m_config->identifier;
}
const wxString& API_PLUGIN::Name() const
{
return m_config->name;
}
const wxString& API_PLUGIN::Description() const
{
return m_config->description;
}
const PLUGIN_RUNTIME& API_PLUGIN::Runtime() const
{
return m_config->runtime;
}
const std::vector<PLUGIN_ACTION>& API_PLUGIN::Actions() const
{
return m_config->actions;
}
wxString API_PLUGIN::BasePath() const
{
return m_configFile.GetPath();
}
std::optional<PLUGIN_ACTION> API_PLUGIN::createActionFromJson( const nlohmann::json& aJson )
{
// TODO move to tl::expected and give user feedback about parse errors
PLUGIN_ACTION action( *this );
try
{
action.identifier = aJson.at( "identifier" ).get<wxString>();
wxLogTrace( traceApi, wxString::Format( "Plugin: load action %s", action.identifier ) );
action.name = aJson.at( "name" ).get<wxString>();
action.description = aJson.at( "description" ).get<wxString>();
action.entrypoint = aJson.at( "entrypoint" ).get<wxString>();
action.show_button = aJson.contains( "show-button" ) && aJson.at( "show-button" ).get<bool>();
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception while parsing action required keys" );
return std::nullopt;
}
wxFileName f( action.entrypoint );
if( !f.IsRelative() )
{
wxLogTrace( traceApi, wxString::Format( "Plugin: action contains abs path %s; skipping",
action.entrypoint ) );
return std::nullopt;
}
f.Normalize( wxPATH_NORM_ABSOLUTE, m_configFile.GetPath() );
if( !f.IsFileReadable() )
{
wxLogTrace( traceApi, wxString::Format( "WARNING: action entrypoint %s is not readable",
f.GetFullPath() ) );
}
if( aJson.contains( "args" ) && aJson.at( "args" ).is_array() )
{
for( const nlohmann::json& argJs : aJson.at( "args" ) )
{
try
{
action.args.emplace_back( argJs.get<wxString>() );
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception while parsing action args" );
continue;
}
}
}
if( aJson.contains( "scopes" ) && aJson.at( "scopes" ).is_array() )
{
for( const nlohmann::json& scopeJs : aJson.at( "scopes" ) )
{
try
{
action.scopes.insert( magic_enum::enum_cast<PLUGIN_ACTION_SCOPE>(
scopeJs.get<std::string>(), magic_enum::case_insensitive )
.value_or( PLUGIN_ACTION_SCOPE::INVALID ) );
}
catch( ... )
{
wxLogTrace( traceApi, "Plugin: exception while parsing action scopes" );
continue;
}
}
}
auto handleBitmap =
[&]( const std::string& aKey, wxBitmapBundle& aDest )
{
if( aJson.contains( aKey ) && aJson.at( aKey ).is_array() )
{
wxVector<wxBitmap> bitmaps;
for( const nlohmann::json& iconJs : aJson.at( aKey ) )
{
wxFileName iconFile;
try
{
iconFile = iconJs.get<wxString>();
}
catch( ... )
{
continue;
}
iconFile.Normalize( wxPATH_NORM_ABSOLUTE, m_configFile.GetPath() );
wxLogTrace( traceApi,
wxString::Format( "Plugin: action %s: loading icon %s",
action.identifier, iconFile.GetFullPath() ) );
if( !iconFile.IsFileReadable() )
{
wxLogTrace( traceApi, "Plugin: icon file could not be read" );
continue;
}
wxBitmap bmp;
bmp.LoadFile( iconFile.GetFullPath() );
if( bmp.IsOk() )
bitmaps.push_back( bmp );
else
wxLogTrace( traceApi, "Plugin: icon file not a valid bitmap" );
}
aDest = wxBitmapBundle::FromBitmaps( bitmaps );
}
};
handleBitmap( "icons-light", action.icon_light );
handleBitmap( "icons-dark", action.icon_dark );
return action;
}

View File

@ -0,0 +1,391 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 Jon Evans <jon@craftyjon.com>
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <fmt/format.h>
#include <wx/dir.h>
#include <wx/log.h>
#include <wx/utils.h>
#include <api/api_plugin_manager.h>
#include <api/api_server.h>
#include <paths.h>
#include <pgm_base.h>
#include <python_manager.h>
#include <settings/settings_manager.h>
#include <settings/common_settings.h>
const wxChar* const traceApi = wxT( "KICAD_API" );
wxDEFINE_EVENT( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED, wxCommandEvent );
wxDEFINE_EVENT( EDA_EVT_PLUGIN_AVAILABILITY_CHANGED, wxCommandEvent );
API_PLUGIN_MANAGER::API_PLUGIN_MANAGER( wxEvtHandler* aEvtHandler ) :
wxEvtHandler(),
m_parent( aEvtHandler )
{
Bind( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED, &API_PLUGIN_MANAGER::processNextJob, this );
}
class PLUGIN_TRAVERSER : public wxDirTraverser
{
private:
std::function<void( const wxFileName& )> m_action;
public:
explicit PLUGIN_TRAVERSER( std::function<void( const wxFileName& )> aAction )
: m_action( std::move( aAction ) )
{
}
wxDirTraverseResult OnFile( const wxString& aFilePath ) override
{
wxFileName file( aFilePath );
if( file.GetFullName() == wxS( "plugin.json" ) )
m_action( file );
return wxDIR_CONTINUE;
}
wxDirTraverseResult OnDir( const wxString& dirPath ) override
{
return wxDIR_CONTINUE;
}
};
void API_PLUGIN_MANAGER::ReloadPlugins()
{
m_plugins.clear();
m_pluginsCache.clear();
m_actionsCache.clear();
m_environmentCache.clear();
m_buttonBindings.clear();
m_menuBindings.clear();
m_readyPlugins.clear();
// TODO support system-provided plugins
wxDir userPluginsDir( PATHS::GetUserPluginsPath() );
PLUGIN_TRAVERSER loader(
[&]( const wxFileName& aFile )
{
wxLogTrace( traceApi, wxString::Format( "Manager: loading plugin from %s",
aFile.GetFullPath() ) );
auto plugin = std::make_unique<API_PLUGIN>( aFile );
if( plugin->IsOk() )
{
if( m_pluginsCache.count( plugin->Identifier() ) )
{
wxLogTrace( traceApi,
wxString::Format( "Manager: identifier %s already present!",
plugin->Identifier() ) );
return;
}
else
{
m_pluginsCache[plugin->Identifier()] = plugin.get();
}
for( const PLUGIN_ACTION& action : plugin->Actions() )
m_actionsCache[action.identifier] = &action;
m_plugins.insert( std::move( plugin ) );
}
else
{
wxLogTrace( traceApi, "Manager: loading failed" );
}
} );
if( userPluginsDir.IsOpened() )
{
wxLogTrace( traceApi, wxString::Format( "Manager: scanning user path (%s) for plugins...",
userPluginsDir.GetName() ) );
userPluginsDir.Traverse( loader );
processPluginDependencies();
}
wxCommandEvent* evt = new wxCommandEvent( EDA_EVT_PLUGIN_AVAILABILITY_CHANGED, wxID_ANY );
m_parent->QueueEvent( evt );
}
void API_PLUGIN_MANAGER::InvokeAction( const wxString& aIdentifier )
{
if( !m_actionsCache.count( aIdentifier ) )
return;
const PLUGIN_ACTION* action = m_actionsCache.at( aIdentifier );
const API_PLUGIN& plugin = action->plugin;
if( !m_readyPlugins.count( plugin.Identifier() ) )
{
wxLogTrace( traceApi, wxString::Format( "Manager: Plugin %s is not ready",
plugin.Identifier() ) );
return;
}
wxFileName pluginFile( plugin.BasePath(), action->entrypoint );
pluginFile.Normalize( wxPATH_NORM_ABSOLUTE | wxPATH_NORM_SHORTCUT | wxPATH_NORM_DOTS
| wxPATH_NORM_TILDE, plugin.BasePath() );
wxString pluginPath = pluginFile.GetFullPath();
std::vector<const wchar_t*> args;
std::optional<wxString> py;
switch( plugin.Runtime().type )
{
case PLUGIN_RUNTIME_TYPE::PYTHON:
{
py = PYTHON_MANAGER::GetVirtualPython( plugin.Identifier() );
if( !py )
{
wxLogTrace( traceApi, wxString::Format( "Manager: Python interpreter for %s not found",
plugin.Identifier() ) );
return;
}
args.push_back( py->wc_str() );
if( !pluginFile.IsFileReadable() )
{
wxLogTrace( traceApi, wxString::Format( "Manager: Python entrypoint %s is not readable",
pluginFile.GetFullPath() ) );
return;
}
break;
}
case PLUGIN_RUNTIME_TYPE::EXEC:
{
if( !pluginFile.IsFileExecutable() )
{
wxLogTrace( traceApi, wxString::Format( "Manager: Exec entrypoint %s is not executable",
pluginFile.GetFullPath() ) );
return;
}
break;
};
default:
wxLogTrace( traceApi, wxString::Format( "Manager: unhandled runtime for action %s",
action->identifier ) );
return;
}
args.emplace_back( pluginPath.wc_str() );
for( const wxString& arg : action->args )
args.emplace_back( arg.wc_str() );
args.emplace_back( nullptr );
wxExecuteEnv env;
wxGetEnvMap( &env.env );
env.env[ wxS( "KICAD_API_SOCKET" ) ] = Pgm().GetApiServer().SocketPath();
env.env[ wxS( "KICAD_API_TOKEN" ) ] = Pgm().GetApiServer().Token();
env.cwd = pluginFile.GetPath();
long p = wxExecute( const_cast<wchar_t**>( args.data() ), wxEXEC_ASYNC, nullptr, &env );
if( !p )
{
wxLogTrace( traceApi, wxString::Format( "Manager: launching action %s failed",
action->identifier ) );
}
else
{
wxLogTrace( traceApi, wxString::Format( "Manager: launching action %s -> pid %ld",
action->identifier, p ) );
}
}
std::vector<const PLUGIN_ACTION*> API_PLUGIN_MANAGER::GetActionsForScope( PLUGIN_ACTION_SCOPE aScope )
{
std::vector<const PLUGIN_ACTION*> actions;
for( auto& [identifier, action] : m_actionsCache )
{
if( !m_readyPlugins.count( action->plugin.Identifier() ) )
continue;
if( action->scopes.count( aScope ) )
actions.emplace_back( action );
}
return actions;
}
void API_PLUGIN_MANAGER::processPluginDependencies()
{
for( const std::unique_ptr<API_PLUGIN>& plugin : m_plugins )
{
m_environmentCache[plugin->Identifier()] = wxEmptyString;
if( plugin->Runtime().type != PLUGIN_RUNTIME_TYPE::PYTHON )
{
m_readyPlugins.insert( plugin->Identifier() );
continue;
}
std::optional<wxString> env = PYTHON_MANAGER::GetPythonEnvironment( plugin->Identifier() );
if( !env )
{
wxLogTrace( traceApi, wxString::Format( "Manager: could not create env for %s",
plugin->Identifier() ) );
continue;
}
wxFileName envConfigPath( *env, wxS( "pyvenv.cfg" ) );
if( envConfigPath.IsFileReadable() )
{
wxLogTrace( traceApi, wxString::Format( "Manager: Python env for %s exists at %s",
plugin->Identifier(), *env ) );
JOB job;
job.type = JOB_TYPE::INSTALL_REQUIREMENTS;
job.identifier = plugin->Identifier();
job.plugin_path = plugin->BasePath();
job.env_path = *env;
m_jobs.emplace_back( job );
continue;
}
wxLogTrace( traceApi, wxString::Format( "Manager: will create Python env for %s at %s",
plugin->Identifier(), *env ) );
JOB job;
job.type = JOB_TYPE::CREATE_ENV;
job.identifier = plugin->Identifier();
job.plugin_path = plugin->BasePath();
job.env_path = *env;
m_jobs.emplace_back( job );
}
wxCommandEvent evt;
processNextJob( evt );
}
void API_PLUGIN_MANAGER::processNextJob( wxCommandEvent& aEvent )
{
if( m_jobs.empty() )
{
wxLogTrace( traceApi, "Manager: cleared job queue" );
return;
}
JOB& job = m_jobs.front();
if( job.type == JOB_TYPE::CREATE_ENV )
{
wxLogTrace( traceApi, wxString::Format( "Manager: creating Python env at %s",
job.env_path ) );
PYTHON_MANAGER manager( Pgm().GetCommonSettings()->m_Python.interpreter_path );
manager.Execute( wxString::Format( wxS( "-m venv %s"), job.env_path ),
[=]( int aRetVal, const wxString& aOutput, const wxString& aError )
{
wxLogTrace( traceApi, wxString::Format( "Manager: venv (%d): %s",
aRetVal, aOutput ) );
if( !aError.IsEmpty() )
wxLogTrace( traceApi, wxString::Format( "Manager: venv err: %s", aError ) );
wxCommandEvent* evt = new wxCommandEvent( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED,
wxID_ANY );
QueueEvent( evt );
} );
}
else if( job.type == JOB_TYPE::INSTALL_REQUIREMENTS )
{
wxLogTrace( traceApi, wxString::Format( "Manager: installing dependencies for %s",
job.plugin_path ) );
std::optional<wxString> python = PYTHON_MANAGER::GetVirtualPython( job.identifier );
wxFileName reqs = wxFileName( job.plugin_path, "requirements.txt" );
if( !python )
{
wxLogTrace( traceApi, wxString::Format( "Manager: error: python not found at %s",
job.env_path ) );
wxCommandEvent* evt = new wxCommandEvent( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED,
wxID_ANY );
QueueEvent( evt );
}
else if( !reqs.IsFileReadable() )
{
wxLogTrace( traceApi,
wxString::Format( "Manager: error: requirements.txt not found at %s",
job.plugin_path ) );
wxCommandEvent* evt = new wxCommandEvent( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED,
wxID_ANY );
QueueEvent( evt );
}
else
{
PYTHON_MANAGER manager( *python );
wxString cmd = wxString::Format(
wxS( "-m pip install --no-input --isolated --require-virtualenv "
"--exists-action i -r '%s'" ),
reqs.GetFullPath() );
manager.Execute( cmd,
[=]( int aRetVal, const wxString& aOutput, const wxString& aError )
{
wxLogTrace( traceApi, wxString::Format( "Manager: pip (%d): %s",
aRetVal, aOutput ) );
if( !aError.IsEmpty() )
wxLogTrace( traceApi, wxString::Format( "Manager: pip err: %s", aError ) );
if( aRetVal == 0 )
{
wxLogTrace( traceApi, wxString::Format( "Manager: marking %s as ready",
job.identifier ) );
m_readyPlugins.insert( job.identifier );
wxCommandEvent* availabilityEvt =
new wxCommandEvent( EDA_EVT_PLUGIN_AVAILABILITY_CHANGED, wxID_ANY );
wxTheApp->QueueEvent( availabilityEvt );
}
wxCommandEvent* evt = new wxCommandEvent( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED,
wxID_ANY );
QueueEvent( evt );
} );
}
}
m_jobs.pop_front();
wxLogTrace( traceApi, wxString::Format( "Manager: %zu jobs left in queue", m_jobs.size() ) );
}

View File

@ -49,6 +49,7 @@ KICAD_API_SERVER::KICAD_API_SERVER() :
{
m_server = std::make_unique<KINNG_REQUEST_SERVER>();
m_server->SetCallback( [&]( std::string* aRequest ) { onApiRequest( aRequest ); } );
m_socketPath = m_server->SocketPath();
m_commonHandler = std::make_unique<API_HANDLER_COMMON>();
RegisterHandler( m_commonHandler.get() );

View File

@ -42,9 +42,8 @@ void PANEL_PYTHON_SETTINGS::ResetPanel()
bool PANEL_PYTHON_SETTINGS::TransferDataToWindow()
{
SETTINGS_MANAGER& mgr = Pgm().GetSettingsManager();
COMMON_SETTINGS* settings = mgr.GetCommonSettings();
m_pickerPythonInterpreter->SetFileName( settings->m_Python.interpreter_path );
m_pickerPythonInterpreter->SetFileName( mgr.GetCommonSettings()->m_Python.interpreter_path );
validateInterpreter();
return true;
@ -54,10 +53,10 @@ bool PANEL_PYTHON_SETTINGS::TransferDataToWindow()
bool PANEL_PYTHON_SETTINGS::TransferDataFromWindow()
{
SETTINGS_MANAGER& mgr = Pgm().GetSettingsManager();
COMMON_SETTINGS* settings = mgr.GetCommonSettings();
wxString interpreter = m_pickerPythonInterpreter->GetTextCtrlValue();
if( m_interpreterValid )
settings->m_Python.interpreter_path = m_pickerPythonInterpreter->GetTextCtrlValue();
if( m_interpreterValid || interpreter.IsEmpty() )
mgr.GetCommonSettings()->m_Python.interpreter_path = interpreter;
return true;
}
@ -71,6 +70,20 @@ void PANEL_PYTHON_SETTINGS::OnPythonInterpreterChanged( wxFileDirPickerEvent& ev
void PANEL_PYTHON_SETTINGS::OnBtnDetectAutomaticallyClicked( wxCommandEvent& aEvent )
{
#ifdef __WXMSW__
// TODO(JE) where
#else
wxArrayString output;
if( 0 == wxExecute( wxS( "which -a python" ), output, wxEXEC_SYNC ) )
{
if( !output.IsEmpty() )
{
m_pickerPythonInterpreter->SetPath( output[0] );
validateInterpreter();
}
}
#endif
}
@ -90,7 +103,7 @@ void PANEL_PYTHON_SETTINGS::validateInterpreter()
PYTHON_MANAGER manager( pythonExe.GetFullPath() );
manager.Execute( wxS( "--version" ),
[&]( int aRetCode, const wxString& aStdOut )
[&]( int aRetCode, const wxString& aStdOut, const wxString& aStdErr )
{
wxString msg;

View File

@ -0,0 +1,33 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <nlohmann/json.hpp>
#include <json_conversions.h>
// Specializations to allow directly reading/writing wxStrings from JSON
void to_json( nlohmann::json& aJson, const wxString& aString )
{
aJson = aString.ToUTF8();
}
void from_json( const nlohmann::json& aJson, wxString& aString )
{
aString = wxString( aJson.get<std::string>().c_str(), wxConvUTF8 );
}

View File

@ -62,17 +62,6 @@ wxString PATHS::GetUserPluginsPath()
}
wxString PATHS::GetUserPlugins3DPath()
{
wxFileName tmp;
tmp.AssignDir( PATHS::GetUserPluginsPath() );
tmp.AppendDir( wxT( "3d" ) );
return tmp.GetPath();
}
wxString PATHS::GetUserScriptingPath()
{
wxFileName tmp;

View File

@ -75,6 +75,7 @@
#endif
#ifdef KICAD_IPC_API
#include <api/api_plugin_manager.h>
#include <api/api_server.h>
#endif
@ -556,6 +557,7 @@ bool PGM_BASE::InitPgm( bool aHeadless, bool aSkipPyInit, bool aIsUnitTest )
m_settings_manager = std::make_unique<SETTINGS_MANAGER>( aHeadless );
m_background_jobs_monitor = std::make_unique<BACKGROUND_JOBS_MONITOR>();
m_notifications_manager = std::make_unique<NOTIFICATIONS_MANAGER>();
m_plugin_manager = std::make_unique<API_PLUGIN_MANAGER>( &App() );
// Our unit test mocks break if we continue
// A bug caused InitPgm to terminate early in unit tests and the mocks are...simplistic
@ -603,6 +605,8 @@ bool PGM_BASE::InitPgm( bool aHeadless, bool aSkipPyInit, bool aIsUnitTest )
// Need to create a project early for now (it can have an empty path for the moment)
GetSettingsManager().LoadProject( "" );
m_plugin_manager->ReloadPlugins();
// This sets the maximum tooltip display duration to 10s (up from 5) but only affects
// Windows as other platforms display tooltips while the mouse is not moving
if( !aHeadless )

View File

@ -904,19 +904,6 @@ template<> void JSON_SETTINGS::Set<wxString>( const std::string& aPath, wxString
}
// Specializations to allow directly reading/writing wxStrings from JSON
void to_json( nlohmann::json& aJson, const wxString& aString )
{
aJson = aString.ToUTF8();
}
void from_json( const nlohmann::json& aJson, wxString& aString )
{
aString = wxString( aJson.get<std::string>().c_str(), wxConvUTF8 );
}
template<typename ResultType>
ResultType JSON_SETTINGS::fetchOrDefault( const nlohmann::json& aJson, const std::string& aKey,
ResultType aDefault )

142
include/api/api_plugin.h Normal file
View File

@ -0,0 +1,142 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 Jon Evans <jon@craftyjon.com>
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KICAD_API_PLUGIN_H
#define KICAD_API_PLUGIN_H
#include <memory>
#include <optional>
#include <set>
#include <nlohmann/json_fwd.hpp>
#include <wx/bmpbndl.h>
#include <wx/filename.h>
#include <wx/string.h>
#include <kicommon.h>
struct API_PLUGIN_CONFIG;
class API_PLUGIN;
struct PLUGIN_DEPENDENCY
{
wxString package_name;
wxString version;
};
enum class PLUGIN_RUNTIME_TYPE
{
INVALID,
PYTHON,
EXEC
};
enum class PLUGIN_ACTION_SCOPE
{
INVALID,
PCB,
SCHEMATIC,
FOOTPRINT,
SYMBOL,
KICAD
};
struct PLUGIN_RUNTIME
{
bool FromJson( const nlohmann::json& aJson );
PLUGIN_RUNTIME_TYPE type;
wxString min_version;
std::vector<PLUGIN_DEPENDENCY> dependencies;
};
/**
* An action performed by a plugin via the IPC API
* (not to be confused with ACTION_PLUGIN, the old SWIG plugin system, which will be removed
* in the future)
*/
struct PLUGIN_ACTION
{
PLUGIN_ACTION( const API_PLUGIN& aPlugin ) :
plugin( aPlugin )
{}
wxString identifier;
wxString name;
wxString description;
bool show_button;
wxString entrypoint;
std::set<PLUGIN_ACTION_SCOPE> scopes;
std::vector<wxString> args;
wxBitmapBundle icon_light;
wxBitmapBundle icon_dark;
const API_PLUGIN& plugin;
};
/**
* A plugin that is invoked by KiCad and runs as an external process; communicating with KiCad
* via the IPC API. The plugin metadata is read from a JSON file containing things like which
* actions the plugin is capable of and how to invoke each one.
*/
class KICOMMON_API API_PLUGIN
{
public:
API_PLUGIN( const wxFileName& aConfigFile );
~API_PLUGIN();
bool IsOk() const;
const wxString& Identifier() const;
const wxString& Name() const;
const wxString& Description() const;
const PLUGIN_RUNTIME& Runtime() const;
wxString BasePath() const;
const std::vector<PLUGIN_ACTION>& Actions() const;
private:
friend struct API_PLUGIN_CONFIG;
std::optional<PLUGIN_ACTION> createActionFromJson( const nlohmann::json& aJson );
wxFileName m_configFile;
std::unique_ptr<API_PLUGIN_CONFIG> m_config;
};
/**
* Comparison functor for ensuring API_PLUGINs have unique identifiers
*/
struct CompareApiPluginIdentifiers
{
bool operator()( const std::unique_ptr<API_PLUGIN>& item1, const std::unique_ptr<API_PLUGIN>& item2 ) const
{
return item1->Identifier() < item2->Identifier();
}
};
#endif //KICAD_API_PLUGIN_H

View File

@ -0,0 +1,102 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 Jon Evans <jon@craftyjon.com>
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <deque>
#include <memory>
#include <set>
#include <wx/event.h>
#include <api/api_plugin.h>
#include <kicommon.h>
/**
* Flag to enable debug output related to the API plugin system
*
* Use "KICAD_API" to enable.
*
* @ingroup traceApi
*/
extern const KICOMMON_API wxChar* const traceApi;
/// Internal event used for handling async tasks
wxDECLARE_EVENT( EDA_EVT_PLUGIN_MANAGER_JOB_FINISHED, wxCommandEvent );
/// Notifies other parts of KiCad when plugin availability changes
extern const KICOMMON_API wxEventTypeTag<wxCommandEvent> EDA_EVT_PLUGIN_AVAILABILITY_CHANGED;
/**
* Responsible for loading plugin definitions for API-based plugins (ones that do not run inside
* KiCad itself, but instead are launched as external processes by KiCad)
*/
class KICOMMON_API API_PLUGIN_MANAGER : public wxEvtHandler
{
public:
API_PLUGIN_MANAGER( wxEvtHandler* aParent );
void ReloadPlugins();
void InvokeAction( const wxString& aIdentifier );
std::vector<const PLUGIN_ACTION*> GetActionsForScope( PLUGIN_ACTION_SCOPE aScope );
std::map<int, wxString>& ButtonBindings() { return m_buttonBindings; }
std::map<int, wxString>& MenuBindings() { return m_menuBindings; }
private:
void processPluginDependencies();
void processNextJob( wxCommandEvent& aEvent );
wxEvtHandler* m_parent;
std::set<std::unique_ptr<API_PLUGIN>, CompareApiPluginIdentifiers> m_plugins;
std::map<wxString, const API_PLUGIN*> m_pluginsCache;
std::map<wxString, const PLUGIN_ACTION*> m_actionsCache;
/// Map of plugin identifier to a path for the plugin's virtual environment, if it has one
std::map<wxString, wxString> m_environmentCache;
/// Map of button wx item id to action identifier
std::map<int, wxString> m_buttonBindings;
/// Map of menu wx item id to action identifier
std::map<int, wxString> m_menuBindings;
std::set<wxString> m_readyPlugins;
enum class JOB_TYPE
{
CREATE_ENV,
INSTALL_REQUIREMENTS
};
struct JOB
{
JOB_TYPE type;
wxString identifier;
wxString plugin_path;
wxString env_path;
};
std::deque<JOB> m_jobs;
};

View File

@ -60,6 +60,10 @@ public:
void SetReadyToReply( bool aReady = true ) { m_readyToReply = aReady; }
const std::string& SocketPath() const { return m_socketPath; }
const std::string& Token() const { return m_token; }
private:
/**
@ -83,6 +87,8 @@ private:
std::set<API_HANDLER*> m_handlers;
std::string m_socketPath;
std::string m_token;
bool m_readyToReply;

View File

@ -0,0 +1,33 @@
/*
* This program source code file is part of KiCad, a free EDA CAD application.
*
* Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KICAD_JSON_CONVERSIONS_H
#define KICAD_JSON_CONVERSIONS_H
#include <kicommon.h>
#include <nlohmann/json_fwd.hpp>
#include <wx/string.h>
// Specializations to allow directly reading/writing wxStrings from JSON
KICOMMON_API void to_json( nlohmann::json& aJson, const wxString& aString );
KICOMMON_API void from_json( const nlohmann::json& aJson, wxString& aString );
#endif //KICAD_JSON_CONVERSIONS_H

View File

@ -52,11 +52,6 @@ public:
*/
static wxString GetUserPluginsPath();
/**
* Gets the user path for 3d viewer plugin
*/
static wxString GetUserPlugins3DPath();
/**
* Gets the default path we point users to create projects
*/

View File

@ -53,6 +53,7 @@ class SETTINGS_MANAGER;
class SCRIPTING;
#ifdef KICAD_IPC_API
class API_PLUGIN_MANAGER;
class KICAD_API_SERVER;
#endif
@ -147,6 +148,8 @@ public:
virtual NOTIFICATIONS_MANAGER& GetNotificationsManager() const { return *m_notifications_manager; }
#ifdef KICAD_IPC_API
virtual API_PLUGIN_MANAGER& GetPluginManager() const { return *m_plugin_manager; }
KICAD_API_SERVER& GetApiServer() { return *m_api_server; }
#endif
@ -412,6 +415,7 @@ protected:
std::unique_ptr<wxSingleInstanceChecker> m_pgm_checker;
#ifdef KICAD_IPC_API
std::unique_ptr<API_PLUGIN_MANAGER> m_plugin_manager;
std::unique_ptr<KICAD_API_SERVER> m_api_server;
#endif

View File

@ -23,6 +23,7 @@
#include <nlohmann/json.hpp>
#include <wx/string.h>
#include <vector>
#include <json_conversions.h>
/**
* Contains the json serialization structs for DRC and ERC reports
@ -119,4 +120,4 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE( ERC_REPORT, $schema, source, date, kicad_ver
} // namespace RC_JSON
#endif
#endif

View File

@ -29,6 +29,7 @@
#include <functional>
#include <optional>
#include <settings/json_settings_internals.h>
#include <json_conversions.h>
#include <kicommon.h>

View File

@ -43,6 +43,8 @@ public:
void Reply( const std::string& aReply );
const std::string& SocketPath() const { return m_socketUrl; }
private:
void listenThread();

View File

@ -38,7 +38,8 @@ static const wxString s_defaultCommitMessage = wxS( "Modification from API" );
API_HANDLER_PCB::API_HANDLER_PCB( PCB_EDIT_FRAME* aFrame ) :
API_HANDLER(),
m_frame( aFrame )
m_frame( aFrame ),
m_transactionInProgress( false )
{
registerHandler<RunAction, RunActionResponse>( &API_HANDLER_PCB::handleRunAction );
registerHandler<GetOpenDocuments, GetOpenDocumentsResponse>(

View File

@ -26,6 +26,7 @@
#include <pgm_base.h>
#include <pcb_edit_frame.h>
#include <3d_viewer/eda_3d_viewer_frame.h>
#include <api/api_plugin_manager.h>
#include <fp_lib_table.h>
#include <bitmaps.h>
#include <confirm.h>
@ -265,6 +266,14 @@ PCB_EDIT_FRAME::PCB_EDIT_FRAME( KIWAY* aKiway, wxWindow* aParent ) :
ReCreateVToolbar();
ReCreateOptToolbar();
wxTheApp->Bind( EDA_EVT_PLUGIN_AVAILABILITY_CHANGED,
[&]( wxCommandEvent& aEvt )
{
wxLogTrace( traceApi, "PCB frame: EDA_EVT_PLUGIN_AVAILABILITY_CHANGED" );
ReCreateHToolbar();
aEvt.Skip();
} );
m_propertiesPanel = new PCB_PROPERTIES_PANEL( this, this );
@ -2053,6 +2062,24 @@ void PCB_EDIT_FRAME::PythonSyncProjectName()
}
void PCB_EDIT_FRAME::OnApiPluginMenu( wxCommandEvent& aEvent )
{
API_PLUGIN_MANAGER& mgr = Pgm().GetPluginManager();
if( mgr.MenuBindings().count( aEvent.GetId() ) )
mgr.InvokeAction( mgr.MenuBindings().at( aEvent.GetId() ) );
}
void PCB_EDIT_FRAME::OnApiPluginButton( wxCommandEvent& aEvent )
{
API_PLUGIN_MANAGER& mgr = Pgm().GetPluginManager();
if( mgr.ButtonBindings().count( aEvent.GetId() ) )
mgr.InvokeAction( mgr.ButtonBindings().at( aEvent.GetId() ) );
}
void PCB_EDIT_FRAME::ShowFootprintPropertiesDialog( FOOTPRINT* aFootprint )
{
if( aFootprint == nullptr )

View File

@ -760,6 +760,11 @@ protected:
*/
void AddActionPluginTools();
/**
* Append actions from API plugins to the main toolbar
*/
void AddApiPluginTools();
/**
* Execute action plugin's Run() method and updates undo buffer.
*
@ -781,6 +786,9 @@ protected:
*/
void OnActionPluginButton( wxCommandEvent& aEvent );
void OnApiPluginMenu( wxCommandEvent& aEvent );
void OnApiPluginButton( wxCommandEvent& aEvent );
/**
* Update the state of the GUI after a new board is loaded or created.
*/

View File

@ -24,9 +24,11 @@
#include "pcb_scripting_tool.h"
#include <action_plugin.h>
#include <api/api_plugin_manager.h>
#include <kiface_ids.h>
#include <kiway.h>
#include <macros.h>
#include <pgm_base.h>
#include <python_scripting.h>
#include <tools/pcb_actions.h>
@ -106,6 +108,9 @@ int SCRIPTING_TOOL::reloadPlugins( const TOOL_EVENT& aEvent )
return -1;
}
// TODO move this elsewhere when SWIG plugins are removed
Pgm().GetPluginManager().ReloadPlugins();
if( !m_isFootprintEditor )
{
// Action plugins can be modified, therefore the plugins menu must be updated:

View File

@ -27,10 +27,12 @@
#include <memory>
#include <advanced_config.h>
#include <api/api_plugin_manager.h>
#include <bitmaps.h>
#include <board.h>
#include <board_design_settings.h>
#include <kiface_base.h>
#include <kiplatform/ui.h>
#include <macros.h>
#include <pcb_edit_frame.h>
#include <pcb_layer_box_selector.h>
@ -280,12 +282,23 @@ void PCB_EDIT_FRAME::ReCreateHToolbar()
m_mainToolBar->AddScaledSeparator( this );
m_mainToolBar->Add( PCB_ACTIONS::showEeschema );
// Access to the scripting console
if( SCRIPTING::IsWxAvailable() )
// Add SWIG and API plugins
bool scriptingAvailable = SCRIPTING::IsWxAvailable();
bool haveApiPlugins =
!Pgm().GetPluginManager().GetActionsForScope( PLUGIN_ACTION_SCOPE::PCB ).empty();
if( scriptingAvailable || haveApiPlugins )
{
m_mainToolBar->AddScaledSeparator( this );
m_mainToolBar->Add( PCB_ACTIONS::showPythonConsole, ACTION_TOOLBAR::TOGGLE );
AddActionPluginTools();
if( scriptingAvailable )
{
m_mainToolBar->Add( PCB_ACTIONS::showPythonConsole, ACTION_TOOLBAR::TOGGLE );
AddActionPluginTools();
}
if( haveApiPlugins )
AddApiPluginTools();
}
// after adding the buttons to the toolbar, must call Realize() to reflect the changes
@ -293,6 +306,35 @@ void PCB_EDIT_FRAME::ReCreateHToolbar()
}
void PCB_EDIT_FRAME::AddApiPluginTools()
{
// TODO: Add user control over visibility and order
API_PLUGIN_MANAGER& mgr = Pgm().GetPluginManager();
mgr.ButtonBindings().clear();
std::vector<const PLUGIN_ACTION*> actions = mgr.GetActionsForScope( PLUGIN_ACTION_SCOPE::PCB );
for( auto& action : actions )
{
if( !action->show_button )
continue;
const wxBitmapBundle& icon = KIPLATFORM::UI::IsDarkTheme() && action->icon_dark.IsOk()
? action->icon_dark
: action->icon_light;
wxAuiToolBarItem* button = m_mainToolBar->AddTool( wxID_ANY, wxEmptyString, icon,
action->name );
Connect( button->GetId(), wxEVT_COMMAND_MENU_SELECTED,
wxCommandEventHandler( PCB_EDIT_FRAME::OnApiPluginButton ) );
mgr.ButtonBindings().insert( { button->GetId(), action->identifier } );
}
}
void PCB_EDIT_FRAME::ReCreateOptToolbar()
{
// Note:

View File

@ -22,12 +22,14 @@
#include <utility>
#include "python_manager.h"
#include <paths.h>
#include <python_manager.h>
class PYTHON_PROCESS : public wxProcess
{
public:
PYTHON_PROCESS( std::function<void(int, const wxString&)> aCallback ) :
PYTHON_PROCESS( std::function<void(int, const wxString&, const wxString&)> aCallback ) :
wxProcess(),
m_callback( std::move( aCallback ) )
{}
@ -36,7 +38,7 @@ public:
{
if( m_callback )
{
wxString output;
wxString output, error;
wxInputStream* processOut = GetInputStream();
size_t bytesRead = 0;
@ -48,26 +50,88 @@ public:
bytesRead += processOut->LastRead();
}
m_callback( aStatus, output );
processOut = GetErrorStream();
bytesRead = 0;
while( processOut->CanRead() && bytesRead < MAX_OUTPUT_LEN )
{
char buffer[4096];
buffer[ processOut->Read( buffer, sizeof( buffer ) - 1 ).LastRead() ] = '\0';
error.append( buffer, sizeof( buffer ) );
bytesRead += processOut->LastRead();
}
m_callback( aStatus, output, error );
}
}
static constexpr size_t MAX_OUTPUT_LEN = 1024L * 1024L;
private:
std::function<void(int, const wxString&)> m_callback;
std::function<void(int, const wxString&, const wxString&)> m_callback;
};
void PYTHON_MANAGER::Execute( const wxString& aArgs,
const std::function<void( int, const wxString& )>& aCallback )
const std::function<void( int, const wxString&,
const wxString& )>& aCallback,
const wxExecuteEnv* aEnv )
{
PYTHON_PROCESS* process = new PYTHON_PROCESS( aCallback );
process->Redirect();
wxString cmd = wxString::Format( wxS( "%s %s" ), m_interpreterPath, aArgs );
long pid = wxExecute( cmd, wxEXEC_ASYNC, process );
long pid = wxExecute( cmd, wxEXEC_ASYNC, process, aEnv );
if( pid == 0 )
aCallback( -1, wxEmptyString );
aCallback( -1, wxEmptyString, _( "Process could not be created" ) );
}
wxString PYTHON_MANAGER::FindPythonInterpreter()
{
#ifdef __WXMSW__
// TODO(JE) where
#else
wxArrayString output;
if( 0 == wxExecute( wxS( "which -a python" ), output, wxEXEC_SYNC ) )
{
if( !output.IsEmpty() )
return output[0];
}
#endif
return wxEmptyString;
}
std::optional<wxString> PYTHON_MANAGER::GetPythonEnvironment( const wxString& aNamespace )
{
wxFileName path( PATHS::GetUserCachePath(), wxEmptyString );
path.AppendDir( wxS( "python-environments" ) );
path.AppendDir( aNamespace );
if( !PATHS::EnsurePathExists( path.GetPath() ) )
return std::nullopt;
return path.GetPath();
}
std::optional<wxString> PYTHON_MANAGER::GetVirtualPython( const wxString& aNamespace )
{
std::optional<wxString> envPath = GetPythonEnvironment( aNamespace );
if( !envPath )
return std::nullopt;
wxFileName python( *envPath, wxEmptyString );
python.AppendDir( "bin" );
python.SetFullName( "python" );
if( !python.IsFileExecutable() )
return std::nullopt;
return python.GetFullPath();
}

View File

@ -35,11 +35,23 @@ public:
{}
void Execute( const wxString& aArgs,
const std::function<void(int, const wxString&)>& aCallback );
const std::function<void(int, const wxString&, const wxString&)>& aCallback,
const wxExecuteEnv* aEnv = nullptr );
wxString GetInterpreterPath() const { return m_interpreterPath; }
void SetInterpreterPath( const wxString& aPath ) { m_interpreterPath = aPath; }
/**
* Searches for a Python intepreter on the user's system
* @return the absolute path to a Python interpreter, or an empty string if one was not found
*/
static wxString FindPythonInterpreter();
static std::optional<wxString> GetPythonEnvironment( const wxString& aNamespace );
/// Returns a full path to the python binary in a venv, if it exists
static std::optional<wxString> GetVirtualPython( const wxString& aNamespace );
private:
wxString m_interpreterPath;
};