Skip to content

Commit 6d591b0

Browse files
committed
feat(PyRevit): implement assembly loading for extension DLLs
- Introduced PyRevitAssemblyLoader to load extension DLLs from pyRevit hierarchy. - Added DllCandidate struct to represent potential DLLs for loading. - Enhanced PyRevitExtensionPaths to enumerate DLL candidates from the extension hierarchy. - Updated PyRevitScriptExecutor to ensure assemblies are loaded before script execution. - Bumped Polyfill package version to 10.7.0.
1 parent 87d9e5e commit 6d591b0

4 files changed

Lines changed: 268 additions & 5 deletions

File tree

‎Directory.Packages.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.5"/>
2525
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.5"/>
2626
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4"/>
27-
<GlobalPackageReference Include="Polyfill" Version="10.3.0"/>
27+
<GlobalPackageReference Include="Polyfill" Version="10.7.0"/>
2828
<PackageVersion Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5"/>
2929
<PackageVersion Include="System.Drawing.Common" Version="8.0.25"/>
3030
<PackageVersion Include="System.Net.Http" Version="4.3.4"/>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
using System.Diagnostics;
2+
using System.IO;
3+
using System.Reflection;
4+
using System.Reflection.Metadata;
5+
using System.Reflection.PortableExecutable;
6+
using RevitDevTool.Core;
7+
8+
namespace RevitDevTool.Execution.PyRevit;
9+
10+
/// <summary>
11+
/// Loads extension DLLs from pyRevit hierarchy lib/bin folders into the current AppDomain
12+
/// so IronPython <c>clr.AddReference</c> can resolve them. Load is once per session, no file lock.
13+
/// </summary>
14+
internal static class PyRevitAssemblyLoader
15+
{
16+
private static readonly Lock LoadLock = new();
17+
private static readonly HashSet<string> LoadedNames = new(StringComparer.OrdinalIgnoreCase);
18+
private static bool _initialized;
19+
20+
internal static void EnsureLoaded(string scriptPath)
21+
{
22+
if (_initialized) return;
23+
24+
lock (LoadLock)
25+
{
26+
if (_initialized) return;
27+
28+
var scriptDir = Path.GetDirectoryName(scriptPath);
29+
var candidates = PyRevitExtensionPaths.EnumerateDllCandidates(scriptDir).ToList();
30+
if (candidates.Count == 0)
31+
{
32+
_initialized = true;
33+
return;
34+
}
35+
36+
GetLoadedAssemblies();
37+
var resolved = Resolve(candidates);
38+
LoadAll(resolved);
39+
_initialized = true;
40+
}
41+
}
42+
43+
private static void GetLoadedAssemblies()
44+
{
45+
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
46+
{
47+
var name = asm.GetName().Name;
48+
if (name is not null)
49+
LoadedNames.Add(name);
50+
}
51+
}
52+
53+
private static List<string> Resolve(List<PyRevitExtensionPaths.DllCandidate> candidates)
54+
{
55+
var revitYear = RevitContext.Application.VersionNumber;
56+
57+
var grouped = candidates
58+
.GroupBy(c => c.SimpleName, StringComparer.OrdinalIgnoreCase);
59+
60+
var result = new List<string>();
61+
62+
foreach (var group in grouped)
63+
{
64+
var items = group.ToList();
65+
66+
if (items.Count == 1)
67+
{
68+
if (IsValidAssembly(items[0].FilePath))
69+
result.Add(items[0].FilePath);
70+
continue;
71+
}
72+
73+
result.Add(ResolveConflict(items, revitYear));
74+
}
75+
76+
return result;
77+
}
78+
79+
private static string ResolveConflict(
80+
List<PyRevitExtensionPaths.DllCandidate> items, string revitYear)
81+
{
82+
var yearMatches = items
83+
.Where(c => PathContainsSegment(c.FilePath, revitYear))
84+
.ToList();
85+
86+
if (yearMatches.Count > 0)
87+
return PickBest(yearMatches);
88+
89+
var runtimeMajor = Environment.Version.Major;
90+
var compatible = new List<(PyRevitExtensionPaths.DllCandidate Candidate, int Major)>();
91+
foreach (var item in items)
92+
{
93+
if (!TryReadTfmMajor(item.FilePath, out var major))
94+
continue;
95+
96+
if (major <= runtimeMajor)
97+
compatible.Add((item, major));
98+
}
99+
100+
if (compatible.Count == 0)
101+
return PickBest(items);
102+
103+
var bestMajor = compatible.Max(r => r.Major);
104+
var bestCandidates = compatible
105+
.Where(r => r.Major == bestMajor)
106+
.Select(r => r.Candidate)
107+
.ToList();
108+
109+
return PickBest(bestCandidates);
110+
}
111+
112+
private static string PickBest(List<PyRevitExtensionPaths.DllCandidate> items) =>
113+
items
114+
.OrderBy(c => c.IsLib ? 0 : 1)
115+
.ThenBy(c => c.Depth)
116+
.First()
117+
.FilePath;
118+
119+
private static void LoadAll(List<string> dllPaths)
120+
{
121+
foreach (var dllPath in dllPaths)
122+
{
123+
var simpleName = Path.GetFileNameWithoutExtension(dllPath);
124+
if (LoadedNames.Contains(simpleName))
125+
continue;
126+
127+
try
128+
{
129+
var bytes = File.ReadAllBytes(dllPath);
130+
Assembly.Load(bytes);
131+
LoadedNames.Add(simpleName);
132+
Trace.TraceInformation($"[PyRevit] Loaded extension DLL: {Path.GetFileName(dllPath)}");
133+
}
134+
catch (Exception ex)
135+
{
136+
Trace.TraceWarning($"[PyRevit] Failed to load '{Path.GetFileName(dllPath)}': {ex.Message}");
137+
}
138+
}
139+
}
140+
141+
private static bool PathContainsSegment(string filePath, string segment)
142+
{
143+
var dir = Path.GetDirectoryName(filePath);
144+
if (string.IsNullOrEmpty(dir)) return false;
145+
146+
foreach (var part in dir.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
147+
{
148+
if (string.Equals(part, segment, StringComparison.OrdinalIgnoreCase))
149+
return true;
150+
}
151+
return false;
152+
}
153+
154+
#region PE Metadata
155+
156+
/// <summary>
157+
/// Returns true if the file has valid .NET PE metadata (is a managed assembly).
158+
/// </summary>
159+
private static bool IsValidAssembly(string dllPath)
160+
{
161+
try
162+
{
163+
using var stream = new FileStream(dllPath, FileMode.Open, FileAccess.Read, FileShare.Read);
164+
using var peReader = new PEReader(stream);
165+
return peReader.HasMetadata;
166+
}
167+
catch
168+
{
169+
return false;
170+
}
171+
}
172+
173+
/// <summary>
174+
/// Reads TFM major version from PE metadata. Returns false if not a valid .NET assembly.
175+
/// </summary>
176+
private static bool TryReadTfmMajor(string dllPath, out int major)
177+
{
178+
major = 0;
179+
try
180+
{
181+
using var stream = new FileStream(dllPath, FileMode.Open, FileAccess.Read, FileShare.Read);
182+
using var peReader = new PEReader(stream);
183+
if (!peReader.HasMetadata) return false;
184+
185+
var reader = peReader.GetMetadataReader();
186+
var assemblyDef = reader.GetAssemblyDefinition();
187+
188+
foreach (var attrHandle in assemblyDef.GetCustomAttributes())
189+
{
190+
var attr = reader.GetCustomAttribute(attrHandle);
191+
if (attr.Constructor.Kind != HandleKind.MemberReference) continue;
192+
193+
var ctor = reader.GetMemberReference((MemberReferenceHandle)attr.Constructor);
194+
if (ctor.Parent.Kind != HandleKind.TypeReference) continue;
195+
196+
var typeRef = reader.GetTypeReference((TypeReferenceHandle)ctor.Parent);
197+
if (reader.GetString(typeRef.Name) != "TargetFrameworkAttribute") continue;
198+
199+
var blob = reader.GetBlobReader(attr.Value);
200+
blob.ReadUInt16();
201+
var tfmString = blob.ReadSerializedString();
202+
major = ParseTfmMajor(tfmString);
203+
return true;
204+
}
205+
206+
// Valid .NET assembly but no TargetFrameworkAttribute (old-style) — treat as net4
207+
major = 4;
208+
return true;
209+
}
210+
catch
211+
{
212+
return false;
213+
}
214+
}
215+
216+
private static int ParseTfmMajor(string? tfmString)
217+
{
218+
if (string.IsNullOrEmpty(tfmString)) return 4;
219+
220+
var vIndex = tfmString!.IndexOf("=v", StringComparison.OrdinalIgnoreCase);
221+
if (vIndex < 0) return 4;
222+
223+
var versionSpan = tfmString.AsSpan(vIndex + 2);
224+
var dotIndex = versionSpan.IndexOf('.');
225+
var majorSpan = dotIndex > 0 ? versionSpan[..dotIndex] : versionSpan;
226+
227+
return int.TryParse(majorSpan.ToString(), out var major) ? major : 4;
228+
}
229+
230+
#endregion
231+
}

‎source/RevitDevTool/Execution/PyRevit/PyRevitExtensionPaths.cs‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,47 @@ internal static class PyRevitExtensionPaths
1313
private const string ExtensionSuffix = ".extension";
1414
private const int MaxExtensionAncestorDepth = 12;
1515

16+
internal readonly record struct DllCandidate(string FilePath, string SimpleName, bool IsLib, int Depth);
17+
1618
/// <summary>
1719
/// Yields component folders from extension root down to the script directory
1820
/// (libs first, then bins — matches pyRevit <c>CollectLibraryPaths</c> / <c>CollectBinaryPaths</c>).
1921
/// </summary>
2022
internal static IEnumerable<string> EnumerateHierarchyPaths(string? scriptDirectory)
2123
{
2224
var chain = BuildAncestorChain(scriptDirectory);
23-
foreach (var path in EnumerateComponentPaths(chain, LibDir))
25+
foreach (var (path, _) in EnumerateComponentPaths(chain, LibDir))
2426
yield return path;
2527

26-
foreach (var path in EnumerateComponentPaths(chain, BinDir))
28+
foreach (var (path, _) in EnumerateComponentPaths(chain, BinDir))
2729
yield return path;
2830
}
2931

32+
/// <summary>
33+
/// Enumerates DLL candidates from the hierarchy.
34+
/// <list type="bullet">
35+
/// <item><c>lib/</c> — all <c>*.dll</c> at any depth (root + subfolders)</item>
36+
/// <item><c>bin/</c> — only <c>*.dll</c> in subfolders (skip DLLs directly in bin root)</item>
37+
/// </list>
38+
/// Depth 0 = closest to script (extension root is highest depth).
39+
/// </summary>
40+
internal static IEnumerable<DllCandidate> EnumerateDllCandidates(string? scriptDirectory)
41+
{
42+
var chain = BuildAncestorChain(scriptDirectory);
43+
44+
foreach (var (folderPath, depth) in EnumerateComponentPaths(chain, LibDir))
45+
{
46+
foreach (var dll in EnumerateAllDlls(folderPath))
47+
yield return new DllCandidate(dll, Path.GetFileNameWithoutExtension(dll), IsLib: true, depth);
48+
}
49+
50+
foreach (var (folderPath, depth) in EnumerateComponentPaths(chain, BinDir))
51+
{
52+
foreach (var dll in EnumerateAllDlls(folderPath))
53+
yield return new DllCandidate(dll, Path.GetFileNameWithoutExtension(dll), IsLib: false, depth);
54+
}
55+
}
56+
3057
/// <summary>Script directory first, then parents until <c>*.extension</c> (inclusive).</summary>
3158
private static List<string> BuildAncestorChain(string? startDirectory)
3259
{
@@ -61,16 +88,20 @@ private static bool TryAppendAncestor(List<string> chain, ref string? dir)
6188
return true;
6289
}
6390

64-
private static IEnumerable<string> EnumerateComponentPaths(List<string> chain, string componentDir)
91+
private static IEnumerable<(string Path, int Depth)> EnumerateComponentPaths(
92+
List<string> chain, string componentDir)
6593
{
6694
for (var i = chain.Count - 1; i >= 0; i--)
6795
{
6896
var path = Path.Combine(chain[i], componentDir);
6997
if (Directory.Exists(path))
70-
yield return path;
98+
yield return (path, i);
7199
}
72100
}
73101

102+
private static IEnumerable<string> EnumerateAllDlls(string directory) =>
103+
Directory.EnumerateFiles(directory, "*.dll", SearchOption.AllDirectories);
104+
74105
private static bool IsExtensionRoot(string dir) =>
75106
dir.EndsWith(ExtensionSuffix, StringComparison.OrdinalIgnoreCase);
76107

‎source/RevitDevTool/Execution/PyRevit/PyRevitScriptExecutor.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ internal static ExecutionResult Execute(string scriptPath, string rootPath)
1212
var uiApplication = RevitContext.UiApplication;
1313
try
1414
{
15+
PyRevitAssemblyLoader.EnsureLoaded(scriptPath);
1516
var reflection = PyRevitReflectionCache.Instance;
1617

1718
if (reflection.HasRuntime)

0 commit comments

Comments
 (0)