Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5d9ec91
Add interactive RevitPythonShell integration to pyRevit
romangolev Jun 7, 2026
457e6b5
refactored interactive shell launcher environment and runtime integra…
romangolev Jun 7, 2026
76fbe39
Merge branch 'develop' into feat/revitpythonshell-inside-pyrevit
romangolev Jun 24, 2026
980ec2f
feat(shell): add dockable interactive Python shell pane
romangolev Jun 24, 2026
c4a2327
chore: delete shell and avalonedit engine binaries
romangolev Jun 24, 2026
54b8b3e
feat(build): wire pyRevit shell into the build pipeline
romangolev Jun 24, 2026
7617b60
fix(shell): use File.Exists so dockable shell loads on .NET Framework…
romangolev Jun 26, 2026
2dcec7e
feat(shell): match Revit UI theme instead of always rendering dark
romangolev Jun 26, 2026
da27dbb
chore(shell): refresh Python Shell pulldown icons
romangolev Jun 26, 2026
4ef39d9
feat(shell): configurable Python Shell mode and deferred dockable init
romangolev Jun 28, 2026
1219c44
feat(shell): theme completion popups to match the console
romangolev Jun 28, 2026
2c9efc0
feat(shell): add code editor above the REPL in modal and modeless win…
romangolev Jun 29, 2026
6171a5a
fix(shell): make the dockable editor variant work end to end
romangolev Jun 29, 2026
03c54e2
feat(shell): add dev-only host for the interactive Python Shell
romangolev Jul 8, 2026
d7537e9
feat(shell): theme the editor toolbar with fluent glyphs
romangolev Jul 8, 2026
b0ac100
feat(shell): share theme resources and add custom title bars
romangolev Jul 9, 2026
0f3830e
Merge branch 'develop' into feat/revitpythonshell-inside-pyrevit
romangolev Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ build/tests/bin/*
build/tests/obj/*
ci-stamped/
ci-bin-manifest.json

# local-only build overrides (never shipped)
build/appsettings.Development.json
3 changes: 3 additions & 0 deletions build/Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
<None Update="appsettings.Production.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions build/Helpers/PyRevitPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static class PyRevitPaths
public static string Engines2712NetCorePath => Path.Combine(BinPath, "netcore", "engines", "IPY2712PR");

public static string RuntimeSolution => Path.Combine(DevPath, "pyRevitLabs.PyRevit.Runtime", "pyRevitLabs.PyRevit.Runtime.sln");
public static string ShellProject => Path.Combine(DevPath, "pyRevitLabs.PyRevit.Shell", "pyRevitLabs.PyRevit.Shell.csproj");
public static string DirectoryBuildProps => Path.Combine(DevPath, "Directory.Build.props");

public static string TelemetryServerPath => Path.Combine(DevPath, "pyRevitTelemetryServer");
Expand Down
33 changes: 33 additions & 0 deletions build/Modules/BuildShellModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Build.Helpers;
using Build.Options;
using Microsoft.Extensions.Options;
using ModularPipelines.Attributes;
using ModularPipelines.Context;
using ModularPipelines.Modules;

namespace Build.Modules;

// The interactive shell ships into the active engine folder (next to the loader/DLR it runs
// against), so it must build after the loaders/runtime have deployed the IronPython/DLR there.
// Building the per-fork configurations also runs the csproj DeployShell target, which copies
// pyRevitLabs.PyRevit.Shell.dll + ICSharpCode.AvalonEdit.dll into each engine folder.
[DependsOn<BuildRuntimeModule>]
public sealed class BuildShellModule(IOptions<BuildOptions> buildOptions) : Module
{
protected override async Task ExecuteModuleAsync(IModuleContext context, CancellationToken cancellationToken)
{
var configuration = buildOptions.Value.Configuration;

await DotNetBuildHelper.BuildProjectAsync(
context,
PyRevitPaths.ShellProject,
configuration + " IPY2712PR",
cancellationToken);

await DotNetBuildHelper.BuildProjectAsync(
context,
PyRevitPaths.ShellProject,
configuration + " IPY342",
cancellationToken);
}
}
1 change: 1 addition & 0 deletions build/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
builder.Services.AddModule<BuildLoadersModule>();
builder.Services.AddModule<BuildRuntimeModule>();
builder.Services.AddModule<BuildRunnersModule>();
builder.Services.AddModule<BuildShellModule>();
builder.Services.AddModule<StageBinAssetsModule>();
builder.Services.AddModule<BuildAutocompModule>();
builder.Services.AddModule<VerifyLibGit2Module>();
Expand Down
1 change: 1 addition & 0 deletions dev/_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ def build_binaries(_: Dict[str, str]):
labs.build_labs(_)
labs.build_engines(_)
labs.build_runtime(_)
labs.build_shell(_)
autoc.build_autocmp(_)
11 changes: 11 additions & 0 deletions dev/_labs.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,14 @@ def build_runtime(args: Dict[str, str]):

_build(f"runtime {IPY2712PR}", configs.RUNTIME, config=config + f" {IPY2712PR}")
_build(f"runtime {IPY342}", configs.RUNTIME, config=config + f" {IPY342}")


def build_shell(args: Dict[str, str]):
"""Build pyRevit interactive shell (per IronPython fork, all target frameworks)."""
config = args.get("<config>") or "Release"

IPY2712PR = "IPY2712PR"
IPY342 = "IPY342"

_build(f"shell {IPY2712PR}", configs.SHELL, config=config + f" {IPY2712PR}")
_build(f"shell {IPY342}", configs.SHELL, config=config + f" {IPY342}")
56 changes: 56 additions & 0 deletions dev/pyRevitLabs.PyRevit.Runtime/InteractiveEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Generic;

using Autodesk.Revit.UI;

namespace PyRevitLabs.PyRevit.Runtime {
/// <summary>
/// Thin entry used by the interactive shell to configure its IronPython engine exactly like a
/// pyRevit script run: full builtins (incl. <c>__scriptruntime__</c>, so
/// <c>from pyrevit import ...</c> works), RevitAPI loaded, the active fork's bundled stdlib, and
/// the caller's sys.path. Built into the per-fork runtime, so it operates on the same DLR
/// identity as the engine pyRevit is configured to use. The shell's console owns stdin/stdout,
/// so engine streams are intentionally left untouched here.
/// </summary>
public static class InteractiveEngine {
/// <summary>
/// Apply the standard pyRevit environment to an interactive shell's IronPython engine
/// (the one its DLR console already created for the active fork).
/// </summary>
public static void ConfigureIronPythonEngine(Microsoft.Scripting.Hosting.ScriptEngine engine, UIApplication uiapp, IList<string> searchPaths) {
var scriptData = new ScriptData {
ScriptPath = "interactive_shell.py",
ConfigScriptPath = "interactive_shell.py",
CommandName = "Interactive Shell",
CommandBundle = "pyRevit Shell",
CommandExtension = "pyRevitCore",
CommandUniqueId = "pyrevit-interactive-shell",
CommandControlId = "pyrevit-interactive-shell",
};

var configs = new ScriptRuntimeConfigs {
UIApp = uiapp,
SearchPaths = searchPaths != null ? new List<string>(searchPaths) : new List<string>(),
Arguments = new List<string>(),
Variables = new Dictionary<string, object>(),
EngineConfigs = "{\"clean\":false,\"full_frame\":false,\"persistent\":true}",
};

var runtime = new ScriptRuntime(scriptData, configs);

// mirror IronPythonEngine.Start's assembly access
engine.Runtime.LoadAssembly(typeof(PyRevitLoader.ScriptExecutor).Assembly);
engine.Runtime.LoadAssembly(typeof(ScriptExecutor).Assembly);
engine.Runtime.LoadAssembly(typeof(Autodesk.Revit.DB.Document).Assembly);
engine.Runtime.LoadAssembly(typeof(Autodesk.Revit.UI.TaskDialog).Assembly);

// pyRevit's bundled standard library for the active fork
new PyRevitLoader.ScriptExecutor().AddEmbeddedLib(engine);

// sys.path captured from the launching pyRevit engine (pyrevitlib, site-packages, ...)
engine.SetSearchPaths(configs.SearchPaths);

// full pyRevit builtins so the REPL matches a normal script's environment
IronPythonEngine.InjectBuiltins(engine, runtime, recoveredFromCache: false, typeId: "pyrevit-interactive-shell");
}
}
}
13 changes: 10 additions & 3 deletions dev/pyRevitLabs.PyRevit.Runtime/IronPythonEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,19 +209,26 @@ private void SetupStreams(ref ScriptRuntime runtime) {
}

private void SetupBuiltins(ref ScriptRuntime runtime) {
InjectBuiltins(Engine, runtime, RecoveredFromCache, TypeId);
}

// Inject the standard pyRevit builtins onto an engine's builtin module. Shared with the
// interactive shell so a REPL gets the same environment (incl. __scriptruntime__) as a
// normal script run.
internal static void InjectBuiltins(Microsoft.Scripting.Hosting.ScriptEngine engine, ScriptRuntime runtime, bool recoveredFromCache, string typeId) {
// BUILTINS -----------------------------------------------------------------------------------------------
// Get builtin to add custom variables
var builtin = IronPython.Hosting.Python.GetBuiltinModule(Engine);
var builtin = IronPython.Hosting.Python.GetBuiltinModule(engine);

// Add timestamp and executuin uuid
builtin.SetVariable("__execid__", runtime.ExecId);
builtin.SetVariable("__timestamp__", runtime.ExecTimestamp);

// Let commands know if they're being run in a cached engine
builtin.SetVariable("__cachedengine__", RecoveredFromCache);
builtin.SetVariable("__cachedengine__", recoveredFromCache);

// Add current engine id to builtins
builtin.SetVariable("__cachedengineid__", TypeId);
builtin.SetVariable("__cachedengineid__", typeId);

// Add this script executor to the the builtin to be globally visible everywhere
// This support pyrevit functionality to ask information about the current executing command
Expand Down
163 changes: 163 additions & 0 deletions dev/pyRevitLabs.PyRevit.Shell.DevHost/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
using Microsoft.Scripting.Hosting;
using PyRevitLabs.PyRevit.Shell;
using PythonConsoleControl;

namespace PyRevitLabs.PyRevit.Shell.DevHost;

/// <summary>
/// Development-only launcher that hosts the interactive Python Shell window outside of Revit, so
/// the REPL/editor can be iterated on without starting Revit. It is not built by the product
/// pipeline and is never shipped to end users.
///
/// It mirrors <c>ShellLauncher</c>'s modal path but drops the Revit-specific pieces
/// (UIApplication, ExternalEvent dispatch, runtime-builtin injection): statements run on the UI
/// dispatcher and the engine gets a plain IronPython environment with pyRevit's library paths on
/// sys.path.
/// </summary>
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
var root = FindRepositoryRoot();
var engineDir = Path.Combine(root, "bin", "netcore", "engines", "IPY2712PR");

if (!File.Exists(Path.Combine(engineDir, "pyRevitLabs.PyRevit.Shell.dll")))
{
Console.Error.WriteLine(
"The shell is not built yet. Build it first, e.g.:" + Environment.NewLine +
" cd build && dotnet run -c Release -- ci" + Environment.NewLine +
"or just the shell:" + Environment.NewLine +
" dotnet build dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj -c \"Release IPY2712PR\"");
return 1;
}
Comment on lines +31 to +39

var searchPaths = BuildSearchPaths(root, engineDir);
var useDark = args.Contains("--dark", StringComparer.OrdinalIgnoreCase);
var consoleOnly = args.Contains("--console", StringComparer.OrdinalIgnoreCase);

Window window;
IronPythonConsoleControl consoleControl;

if (consoleOnly)
{
var shell = new InteractiveShellWindow();
shell.ApplyTheme(useDark);
window = shell;
consoleControl = shell.ConsoleControl;
}
else
{
var editor = new InteractiveEditorWindow();
editor.ApplyTheme(useDark);
window = editor;
consoleControl = editor.ConsoleControl;
}

var mainDispatcher = Dispatcher.CurrentDispatcher;

consoleControl.WithConsoleHost(host =>
{
// Statements run on the UI dispatcher, matching the in-Revit modal dispatch so this
// host behaves like the real shell rather than the console's native background thread.
host.Console.SetCommandDispatcher(command => RunOnDispatcher(mainDispatcher, command));
host.Editor.SetCompletionDispatcher(command => RunOnDispatcher(mainDispatcher, command));

ConfigureStandaloneEngine(host.Engine, host.Console.ScriptScope, searchPaths);
host.Console.ScriptScope.SetVariable("__window__", window);
});

window.ShowDialog();
return 0;
}

private static IList<string> BuildSearchPaths(string root, string engineDir)
{
var paths = new List<string> { engineDir };

var stdlibZip = Path.Combine(root, "dev", "libs", "IronPython", "python_2712pr_lib.zip");
if (File.Exists(stdlibZip))
paths.Add(stdlibZip);

AddIfExists(paths, Path.Combine(root, "pyrevitlib"));
AddIfExists(paths, Path.Combine(root, "site-packages"));
AddIfExists(paths, Path.Combine(root, "extensions"));
return paths;
}

private static void AddIfExists(List<string> paths, string dir)
{
if (Directory.Exists(dir))
paths.Add(dir);
}

private static void ConfigureStandaloneEngine(ScriptEngine engine, ScriptScope scope, IList<string> searchPaths)
{
engine.SetSearchPaths(searchPaths);

// Safe defaults for the reserved pyrevit builtins the interactive shell expects, so user
// snippets that read them don't NameError in the no-Revit host.
try
{
engine.Execute(StubBuiltinsSnippet, scope);
}
catch
{
// Best effort: the REPL still works without these defaults.
}
}

// Poll the dispatcher operation so a Ctrl+C keyboard interrupt can break a long run.
private static void RunOnDispatcher(Dispatcher dispatcher, Action command)
{
if (command == null)
return;

var operation = dispatcher.BeginInvoke(DispatcherPriority.Normal, command);
while (operation.Status != DispatcherOperationStatus.Completed)
operation.Wait(TimeSpan.FromSeconds(1));
}

private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "pyRevitfile")))
return current.FullName;
current = current.Parent;
}

return Directory.GetCurrentDirectory();
}

private const string StubBuiltinsSnippet =
"try: __execid__\nexcept NameError: __execid__ = ''\n" +
"try: __timestamp__\nexcept NameError: __timestamp__ = ''\n" +
"try: __cachedengine__\nexcept NameError: __cachedengine__ = False\n" +
"try: __cachedengineid__\nexcept NameError: __cachedengineid__ = None\n" +
"try: __scriptruntime__\nexcept NameError: __scriptruntime__ = None\n" +
"try: __revit__\nexcept NameError: __revit__ = None\n" +
"try: __commanddata__\nexcept NameError: __commanddata__ = None\n" +
"try: __elements__\nexcept NameError: __elements__ = None\n" +
"try: __uibutton__\nexcept NameError: __uibutton__ = None\n" +
"try: __commandpath__\nexcept NameError: __commandpath__ = ''\n" +
"try: __configcommandpath__\nexcept NameError: __configcommandpath__ = ''\n" +
"try: __commandname__\nexcept NameError: __commandname__ = 'Interactive Shell (dev)'\n" +
"try: __commandbundle__\nexcept NameError: __commandbundle__ = 'pyRevit Shell'\n" +
"try: __commandextension__\nexcept NameError: __commandextension__ = 'pyRevitCore'\n" +
"try: __commanduniqueid__\nexcept NameError: __commanduniqueid__ = 'pyrevit-interactive-shell'\n" +
"try: __commandcontrolid__\nexcept NameError: __commandcontrolid__ = 'pyrevit-interactive-shell'\n" +
"try: __forceddebugmode__\nexcept NameError: __forceddebugmode__ = False\n" +
"try: __shiftclick__\nexcept NameError: __shiftclick__ = False\n" +
"try: __result__\nexcept NameError: __result__ = {}\n" +
"try: __eventsender__\nexcept NameError: __eventsender__ = None\n" +
"try: __eventargs__\nexcept NameError: __eventargs__ = None\n";
}

Loading