Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
81cf615
Add docs
vietle-bh Oct 7, 2026
848ac73
Clean up template
vietle-bh Oct 7, 2026
35affc2
Add reflection-based generation code
vietle-bh Oct 8, 2026
c59eff2
Add custom py2cs package!
vietle-bh Oct 26, 2026
3dd9b6d
Add unit tests!
vietle-bh Oct 26, 2026
286bfc3
Add test output from converting BHoM repo
vietle-bh Oct 26, 2026
5451d38
Create Converting-All-oM-Classes.md
vietle-bh Oct 26, 2026
4777dcd
Create Existing-Tools-Evaluation.md
vietle-bh Oct 26, 2026
ba3cbd9
Create Reusing-oM-Classes.md
vietle-bh Oct 26, 2026
7b20401
Create Generating-the-Real-Package.md
vietle-bh Oct 26, 2026
7258cb9
Update golden dataset
vietle-bh Oct 26, 2026
9f178fa
Handle more edge cases
vietle-bh Oct 26, 2026
5a5e142
Add fix plan
vietle-bh Oct 26, 2026
78f57c0
Update C# class at member level
vietle-bh Oct 28, 2026
5ed1d60
Update CODING-PLAN.md
vietle-bh Oct 28, 2026
07e83cc
Keep the Python and C# PRs in sync
vietle-bh Oct 28, 2026
f4fc0ed
Update tests to reference CSharpSchema_Test
vietle-bh Oct 28, 2026
463ae29
Run test conversion
vietle-bh Oct 28, 2026
9c45b1a
Add workflow files
vietle-bh Oct 28, 2026
4e6ea1e
Add PR-generating code
vietle-bh Oct 29, 2026
6ea0c49
Remove building dKoP on Github
vietle-bh Oct 29, 2026
b85ab87
Create Private-Repositories.md
vietle-bh Oct 29, 2026
1d25e70
Improve code documentation
vietle-bh Oct 29, 2026
04676a9
Clean unused code
vietle-bh Oct 29, 2026
a45e7e1
Update Fix-Plan.md
vietle-bh Oct 29, 2026
7b64306
Handle interfaces better
vietle-bh Oct 29, 2026
8ffc112
Add release scripts
vietle-bh Oct 29, 2026
aef2ee9
Get version from any BHoM DLL file
vietle-bh Oct 29, 2026
a486608
Update run-all.ps1
vietle-bh Oct 29, 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
12 changes: 12 additions & 0 deletions .ci/BHoMReferences.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<!-- BHoM assemblies installed by building the BHoM repos (post-build copies them to ProgramData). -->
<PropertyGroup>
<BHoMAssemblies>$(ProgramData)\BHoM\Assemblies</BHoMAssemblies>
</PropertyGroup>
<ItemGroup>
<Reference Include="BHoM">
<HintPath>$(BHoMAssemblies)\BHoM.dll</HintPath>
<SpecificVersion>false</SpecificVersion>
</Reference>
</ItemGroup>
</Project>
72 changes: 72 additions & 0 deletions .ci/generation/PythonSchemaGeneration.Core/CycleDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.ComponentModel;

namespace PythonSchemaGeneration
{
//Finds groups of types that reference each other (strongly connected components)
public static class CycleDetector
{
/***************************************************/

[Description("Finds the groups of types that reference each other (strongly connected components, Tarjan's algorithm) in the given reference graph and returns a " +
"predicate telling whether two types are in the same cycle of more than one type. The Generator uses it after the first rendering pass so that only " +
"imports inside a cycle are written after the class.")]
public static Func<Type, Type, bool> DetectFunction(Dictionary<Type, IEnumerable<Type>> references)
{
int counter = 0;
int components = 0;
Stack<Type> stack = new Stack<Type>();
HashSet<Type> onStack = new HashSet<Type>();
Dictionary<Type, int> low = new Dictionary<Type, int>();
Dictionary<Type, int> index = new Dictionary<Type, int>();
Dictionary<Type, int> component = new Dictionary<Type, int>();

foreach (Type node in references.Keys.OrderBy(t => t.FullName, StringComparer.Ordinal))
{
if (!index.ContainsKey(node))
Visit(node, references, ref counter, ref components, stack, onStack, low, index, component);
}

Dictionary<int, int> sizes = component.GroupBy(kv => kv.Value).ToDictionary(g => g.Key, g => g.Count());
return (a, b) => component.TryGetValue(a, out int ca) && component.TryGetValue(b, out int cb) && ca == cb && sizes[ca] > 1;
}

/***************************************************/

[Description("Visits one node of the depth-first search of Tarjan's algorithm: follows its references, updates the low-link numbers and assigns the node's strongly " +
"connected component number once the component is complete. Called by DetectFunction.")]
private static void Visit(Type node, Dictionary<Type, IEnumerable<Type>> references, ref int counter, ref int components, Stack<Type> stack, HashSet<Type> onStack, Dictionary<Type, int> low, Dictionary<Type, int> index, Dictionary<Type, int> component)
{
index[node] = low[node] = counter++;
stack.Push(node);
onStack.Add(node);

if (references.TryGetValue(node, out IEnumerable<Type> targets))
{
foreach (Type target in targets.Where(t => t != node && references.ContainsKey(t)))
{
if (!index.ContainsKey(target))
{
Visit(target, references, ref counter, ref components, stack, onStack, low, index, component);
low[node] = Math.Min(low[node], low[target]);
}
else if (onStack.Contains(target))
low[node] = Math.Min(low[node], index[target]);
}
}

if (low[node] == index[node])
{
Type member;
do
{
member = stack.Pop();
onStack.Remove(member);
component[member] = components;
} while (member != node);
components++;
}
}

/***************************************************/
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
using System.ComponentModel;
using System.Collections;
using System.Globalization;
using System.Text.RegularExpressions;
using PythonSchemaGeneration.Mapping;
using PythonSchemaGeneration.Model;
using PythonSchemaGeneration.Reporting;

namespace PythonSchemaGeneration.Emission
{
public enum DefaultKind { None, Literal, Factory }

public class DefaultText
{
public DefaultKind Kind = DefaultKind.None;
public string Text;
public bool IsNull; //the default is null, so the annotation must allow None
}

//Turns the value read from a fresh C# instance into Python source
public class DefaultFormatter
{
private readonly TypeRegistry m_Registry;
private readonly Reporter m_Reporter;
private readonly NameTracker m_Scope;
private readonly string m_Context;
private readonly HashSet<string> m_TypeParams;

[Description("Creates a formatter that writes property default values as Python source, using the registry, reporter, import tracker, owning type name and "
+ "its generic parameter names. PythonEmitter builds one per type.")]
public DefaultFormatter(TypeRegistry registry, Reporter reporter, NameTracker scope, string context, IEnumerable<string> typeParams)
{
m_Registry = registry;
m_Reporter = reporter;
m_Scope = scope;
m_Context = context;
m_TypeParams = new HashSet<string>(typeParams);
}

/***************************************************/

[Description("Converts the default value read from a fresh C# instance into Python source: a literal, a factory (for fresh objects, collections, dates, "
+ "GUIDs) or nothing when the field must be required, warning when the value cannot be written. PythonEmitter calls it for every property while "
+ "writing the fields.")]
public DefaultText Format(PropertyModel property, MappedType mapped)
{
if (property.Default == null)
return new DefaultText();

object value = property.Default.Value;
if (value == null)
return new DefaultText { Kind = DefaultKind.Literal, Text = "None", IsNull = true };

Type vt = value.GetType();
string member = m_Context + "." + property.Name;

if (property.Default.NonDeterministic)
{
if (vt == typeof(Guid))
return Factory(m_Scope.AddStandardName("uuid", "uuid4"));
return Factory(m_Scope.AddStandardName("datetime", "datetime") + ".now");
}

if (vt == typeof(bool)) return Literal(PyLiteral.Bool((bool)value));
if (vt == typeof(char)) return Literal(PyLiteral.Str(value.ToString()));
if (vt == typeof(string)) return Literal(PyLiteral.Str((string)value));
if (vt == typeof(double)) return Literal(PyLiteral.Float((double)value));
if (vt == typeof(float)) return Literal(PyLiteral.Float(Convert.ToDouble(value.ToString(), CultureInfo.InvariantCulture)));
if (vt == typeof(decimal))
return Literal($"{m_Scope.AddStandardName("decimal", "Decimal")}({PyLiteral.Str(((decimal)value).ToString(CultureInfo.InvariantCulture))})");
if (vt.IsPrimitive)
return Literal(Convert.ToString(value, CultureInfo.InvariantCulture));

if (vt.IsEnum)
return Enum(vt, value, member);

if (vt == typeof(Guid))
{
Guid guid = (Guid)value;
string uuid = m_Scope.AddStandardName("uuid", "UUID");
return Literal(guid == Guid.Empty ? $"{uuid}(int=0)" : $"{uuid}({PyLiteral.Str(guid.ToString())})");
}
if (vt == typeof(DateTime) || vt == typeof(DateTimeOffset))
{
DateTime dt = vt == typeof(DateTime) ? (DateTime)value : ((DateTimeOffset)value).UtcDateTime;
string datetime = m_Scope.AddStandardName("datetime", "datetime");
if (dt == DateTime.MinValue)
return Literal(datetime + ".min");
return Literal($"{datetime}({dt.Year}, {dt.Month}, {dt.Day}, {dt.Hour}, {dt.Minute}, {dt.Second}, {(int)((dt.Ticks % TimeSpan.TicksPerSecond) / 10)})");
}
if (vt == typeof(TimeSpan))
{
TimeSpan ts = (TimeSpan)value;
string timedelta = m_Scope.AddStandardName("datetime", "timedelta");
return Literal(ts == TimeSpan.Zero ? $"{timedelta}(0)" : $"{timedelta}(seconds={PyLiteral.Float(ts.TotalSeconds)})");
}
if (vt.FullName == "System.Drawing.Color")
return ColorDefault(value);

if (value is IEnumerable enumerable)
return Collection(enumerable, mapped, member);

if (m_Registry.Contains(vt) && !vt.IsAbstract && (vt.IsValueType || vt.GetConstructor(Type.EmptyTypes) != null))
{
//The Python class cannot be created without arguments if one of its own defaults was left out
if (HasUnwritableEnum(value, 0))
{
m_Reporter.Warn(member, $"default instance of {ClrNames.Format(vt)} cannot be created in Python, field is required");
return new DefaultText();
}

PyRef pyRef = m_Registry.Ref(vt);
return Factory($"lambda: {m_Scope.Reference(vt, pyRef, false)}()");
}

m_Reporter.Warn(member, $"default value of type {ClrNames.Format(vt)} cannot be written in Python, field is required");
return new DefaultText();
}

/***************************************************/

[Description("Wraps Python source text as a literal default. Used by Format and its helpers.")]
private static DefaultText Literal(string text)
{
return new DefaultText { Kind = DefaultKind.Literal, Text = text };
}

[Description("Wraps Python source text as a default_factory default. Used by Format and its helpers.")]
private static DefaultText Factory(string text)
{
return new DefaultText { Kind = DefaultKind.Factory, Text = text };
}

/***************************************************/

[Description("Writes an enum default as a reference to the generated enum member, or as the enum called with the raw number for an undefined flags value; "
+ "warns and leaves the field required for an undefined value of a plain enum. Called by Format.")]
private DefaultText Enum(Type type, object value, string member)
{
if (!m_Registry.Contains(type))
return Literal(PyLiteral.Str(value.ToString()));

string local = m_Scope.Reference(type, m_Registry.Ref(type), true);
if (System.Enum.IsDefined(type, value))
return Literal($"{local}.{Naming.Identifier(value.ToString())}");

//A plain Python Enum rejects a value that is not a member, so only flags enums can carry one
if (!type.IsDefined(typeof(FlagsAttribute), false))
{
m_Reporter.Warn(member, $"default value {value:D} is not a member of enum {ClrNames.Format(type)}, field is required");
return new DefaultText();
}

object raw = Convert.ChangeType(value, System.Enum.GetUnderlyingType(type));
return Literal($"{local}({Convert.ToString(raw, CultureInfo.InvariantCulture)})");
}

[Description("Writes a System.Drawing.Color default as a factory for the support package Color class, read from its A, R, G, B values. Called by Format.")]
private DefaultText ColorDefault(object value)
{
Type t = value.GetType();
int a = Convert.ToInt32(t.GetProperty("A").GetValue(value));
int r = Convert.ToInt32(t.GetProperty("R").GetValue(value));
int g = Convert.ToInt32(t.GetProperty("G").GetValue(value));
int b = Convert.ToInt32(t.GetProperty("B").GetValue(value));
string color = m_Scope.AddStandardName("bhom_schema._support", "Color");
if (a == 0 && r == 0 && g == 0 && b == 0)
return Factory(color);
return Factory($"lambda: {color}(A={a}, R={r}, G={g}, B={b})");
}

/***************************************************/

[Description("Writes a list, set, dict or array default as an empty or literal-filled factory, falling back to an empty collection with a warning when the "
+ "items are complex or the container is not supported. Called by Format.")]
private DefaultText Collection(IEnumerable enumerable, MappedType mapped, string member)
{
string expr = mapped.Expression.Replace(" | None", "");
string head = expr.Split('[')[0];
List<object> items = enumerable.Cast<object>().ToList();

//Containers that mention a type parameter need the subscripted form so the factory stays typed
if (head != "list" && head != "set" && head != "dict")
{
m_Reporter.Warn(member, $"default collection mapped to {expr} is not supported, field is required");
return new DefaultText();
}

//A lambda is evaluated after the class exists, so it can name the class itself or a type parameter
bool generic = m_TypeParams.Any(p => Regex.IsMatch(expr, $@"\b{Regex.Escape(p)}\b"));
string factory = generic ? (head == "set" ? "lambda: set()" : head == "dict" ? "lambda: {}" : "lambda: []") : head;

if (enumerable is Array array && array.Rank == 2)
return Matrix(array, factory, member);

if (items.Count == 0)
return Factory(factory);

List<string> literals = new List<string>();
foreach (object item in items)
{
string text = SimpleLiteral(item);
if (text == null)
{
m_Reporter.Warn(member, "default collection holds complex items, an empty collection is used instead");
return Factory(factory);
}
literals.Add(text);
}
if (head == "dict")
{
m_Reporter.Warn(member, "non-empty default dictionary is not written, an empty dictionary is used instead");
return Factory(factory);
}
string body = string.Join(", ", literals);
return Factory(head == "set" ? $"lambda: {{{body}}}" : $"lambda: [{body}]");
}

[Description("Checks whether a default object, or an object nested up to a few levels inside it, holds an enum value that the generated Python enum cannot "
+ "represent. Format uses it to decide the Python class cannot be created without arguments.")]
private bool HasUnwritableEnum(object value, int depth)
{
if (value == null || depth > 4)
return false;

Type type = value.GetType();
foreach (System.Reflection.PropertyInfo property in type.GetProperties())
{
if (property.GetIndexParameters().Length > 0 || !property.CanRead)
continue;

object item;
try { item = property.GetValue(value); }
catch { continue; }

if (item == null)
continue;

Type itemType = item.GetType();
if (itemType.IsEnum && m_Registry.Contains(itemType))
{
if (!System.Enum.IsDefined(itemType, item) && !itemType.IsDefined(typeof(FlagsAttribute), false))
return true;
}
else if (m_Registry.Contains(itemType) && !itemType.IsEnum && HasUnwritableEnum(item, depth + 1))
return true;
}

return false;
}

[Description("Writes a two-dimensional array default as a factory returning a list of row lists, or an empty factory with a warning when a cell is complex. Called by Collection.")]
private DefaultText Matrix(Array array, string emptyFactory, string member)
{
List<string> rows = new List<string>();
for (int row = array.GetLowerBound(0); row <= array.GetUpperBound(0); row++)
{
List<string> cells = new List<string>();
for (int column = array.GetLowerBound(1); column <= array.GetUpperBound(1); column++)
{
string text = SimpleLiteral(array.GetValue(row, column));
if (text == null)
{
m_Reporter.Warn(member, "default array holds complex items, an empty collection is used instead");
return Factory(emptyFactory);
}

cells.Add(text);
}

rows.Add("[" + string.Join(", ", cells) + "]");
}

if (rows.Count == 0)
return Factory(emptyFactory);

return Factory("lambda: [" + string.Join(", ", rows) + "]");
}

[Description("Returns Python source for a null, bool, string or number item, or null when the item is a complex object. Collection and Matrix use it to "
+ "decide whether a collection default can be written out.")]
private static string SimpleLiteral(object item)
{
switch (item)
{
case null: return "None";
case bool b: return PyLiteral.Bool(b);
case string s: return PyLiteral.Str(s);
case double d: return PyLiteral.Float(d);
case float f: return PyLiteral.Float(f);
}
if (item.GetType().IsPrimitive)
return Convert.ToString(item, CultureInfo.InvariantCulture);
return null;
}
}
}
Loading
Loading