diff --git a/.ci/BHoMReferences.props b/.ci/BHoMReferences.props
new file mode 100644
index 0000000..0d24045
--- /dev/null
+++ b/.ci/BHoMReferences.props
@@ -0,0 +1,12 @@
+
+
+
+ $(ProgramData)\BHoM\Assemblies
+
+
+
+ $(BHoMAssemblies)\BHoM.dll
+ false
+
+
+
diff --git a/.ci/generation/PythonSchemaGeneration.Core/CycleDetector.cs b/.ci/generation/PythonSchemaGeneration.Core/CycleDetector.cs
new file mode 100644
index 0000000..8a65e6c
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/CycleDetector.cs
@@ -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 DetectFunction(Dictionary> references)
+ {
+ int counter = 0;
+ int components = 0;
+ Stack stack = new Stack();
+ HashSet onStack = new HashSet();
+ Dictionary low = new Dictionary();
+ Dictionary index = new Dictionary();
+ Dictionary component = new Dictionary();
+
+ 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 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> references, ref int counter, ref int components, Stack stack, HashSet onStack, Dictionary low, Dictionary index, Dictionary component)
+ {
+ index[node] = low[node] = counter++;
+ stack.Push(node);
+ onStack.Add(node);
+
+ if (references.TryGetValue(node, out IEnumerable 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++;
+ }
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/DefaultFormatter.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/DefaultFormatter.cs
new file mode 100644
index 0000000..2a9ee80
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/DefaultFormatter.cs
@@ -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 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 typeParams)
+ {
+ m_Registry = registry;
+ m_Reporter = reporter;
+ m_Scope = scope;
+ m_Context = context;
+ m_TypeParams = new HashSet(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 items = enumerable.Cast().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 literals = new List();
+ 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 rows = new List();
+ for (int row = array.GetLowerBound(0); row <= array.GetUpperBound(0); row++)
+ {
+ List cells = new List();
+ 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;
+ }
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/ManifestWriter.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/ManifestWriter.cs
new file mode 100644
index 0000000..8ce49d4
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/ManifestWriter.cs
@@ -0,0 +1,117 @@
+using System.ComponentModel;
+using PythonSchemaGeneration.Model;
+using System.Text.Json;
+
+namespace PythonSchemaGeneration.Emission
+{
+ //Language-neutral list of the generated types. Compared with the manifest the Python reader builds in the tests.
+ public static class ManifestWriter
+ {
+ /***************************************************/
+
+ [Description("Serializes the rendered types, sorted by C# name, to the indented language-neutral manifest JSON (schema version 1). The generator writes it "
+ + "next to the Python so the tests and py2cs can compare the Python with what the C# says.")]
+ public static string ToJson(IEnumerable rendered)
+ {
+ List types = rendered
+ .OrderBy(r => r.Model.ClrName, StringComparer.Ordinal)
+ .Select(r => (object)Describe(r))
+ .ToList();
+
+ JsonSerializerOptions options = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ Dictionary root = new Dictionary { ["schemaVersion"] = 1, ["types"] = types };
+ return JsonSerializer.Serialize(root, options);
+ }
+
+ /***************************************************/
+
+ [Description("Builds the manifest entry of one rendered type: names, paths, kind, bases, repo, description, generic parameters, properties with defaults, "
+ + "quantities and display text, and enum members. Called by ToJson for every type.")]
+ private static Dictionary Describe(RenderedType r)
+ {
+ TypeModel model = r.Model;
+ Dictionary data = new Dictionary
+ {
+ ["clrName"] = model.ClrName,
+ ["name"] = model.CanonicalName,
+ ["pythonName"] = r.Ref.Name,
+ ["pythonModule"] = r.Ref.Module,
+ ["relativePath"] = r.Ref.RelativePath + ".py",
+ ["namespace"] = model.Namespace,
+ ["assembly"] = model.Assembly,
+ ["kind"] = PythonEmitter.KindText(model.Kind),
+ ["bases"] = model.Bases.Select(ClrNames.Format).ToList(),
+ };
+
+ if (model.Repo != null)
+ data["repo"] = model.Repo;
+
+ if (model.Description != null)
+ data["description"] = model.Description;
+
+ if (model.IsFlags)
+ data["flags"] = true;
+
+ if (model.GenericParams.Count > 0)
+ {
+ data["genericParams"] = model.GenericParams.Select(g => new Dictionary
+ {
+ ["name"] = g.Name,
+ ["constraints"] = g.Constraints.Select(ClrNames.Format).ToList()
+ }).ToList();
+ }
+
+ data["properties"] = r.Properties.Select(prop =>
+ {
+ Dictionary propData = new Dictionary
+ {
+ ["name"] = prop.Name,
+ ["clrType"] = prop.ClrType,
+ };
+
+ if (model.Kind != TypeKind.Interface)
+ {
+ propData["pyName"] = prop.PyName;
+ propData["hasDefault"] = prop.HasDefault;
+ if (prop.Default != null)
+ propData["default"] = prop.Default;
+ }
+
+ if (prop.Description != null)
+ propData["description"] = prop.Description;
+
+ if (prop.Quantity != null)
+ propData["quantity"] = new Dictionary { ["category"] = prop.Quantity.Category, ["unit"] = prop.Quantity.Unit ?? "" };
+
+ if (prop.DisplayText != null)
+ propData["displayText"] = prop.DisplayText;
+
+ if (prop.IsDynamic)
+ propData["dynamic"] = true;
+
+ return propData;
+ }).ToList();
+
+ if (model.Kind == TypeKind.Enum)
+ {
+ data["members"] = model.EnumMembers.Select(e =>
+ {
+ Dictionary enumData = new Dictionary { ["name"] = e.Name, ["value"] = e.Value };
+ if (e.Description != null)
+ enumData["description"] = e.Description;
+
+ return enumData;
+ }).ToList();
+ }
+
+ return data;
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/OutputWriter.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/OutputWriter.cs
new file mode 100644
index 0000000..ec3e423
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/OutputWriter.cs
@@ -0,0 +1,102 @@
+using System.ComponentModel;
+using PythonSchemaGeneration.Reporting;
+using System.Text;
+
+namespace PythonSchemaGeneration.Emission
+{
+ public static class OutputWriter
+ {
+ /***************************************************/
+
+ private static readonly UTF8Encoding m_Utf8 = new UTF8Encoding(false);
+
+ /***************************************************/
+
+ [Description("Writes the rendered modules (and empty __init__.py files for their folders) under the package root, skipping files whose text is unchanged, "
+ + "and returns the number of files written. With cleanRedundant it first deletes stale .py files in the folders of the written assemblies (only "
+ + "those in cleanAssemblies when given), so the hand-written support package is never touched. The generator's last file-writing stage.")]
+ public static int Write(string packageRoot, IEnumerable rendered, bool cleanRedundant, Reporter reporter, ISet cleanAssemblies = null)
+ {
+ Dictionary fileTextByPath = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (RenderedType type in rendered)
+ {
+ string relativePath = type.Ref.RelativePath + ".py";
+ if (fileTextByPath.ContainsKey(relativePath))
+ {
+ reporter.Warn(type.Model.ClrName, $"output path {relativePath} is already used by another type, skipped");
+ continue;
+ }
+
+ fileTextByPath[relativePath] = type.Text;
+ string[] segments = relativePath.Split('/');
+
+ for (int i = 1; i < segments.Length; i++)
+ {
+ string init = string.Join("/", segments.Take(i)) + "/__init__.py";
+ if (!fileTextByPath.ContainsKey(init))
+ fileTextByPath[init] = "";
+ }
+ }
+
+ if (cleanRedundant)
+ CleanRedundantExistingFiles(packageRoot, fileTextByPath, cleanAssemblies);
+
+ int written = 0;
+ foreach (KeyValuePair file in fileTextByPath.OrderBy(f => f.Key, StringComparer.Ordinal))
+ {
+ string path = Path.Combine(packageRoot, file.Key.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ string text = file.Value.Replace("\r\n", "\n");
+
+ if (File.Exists(path) && File.ReadAllText(path, m_Utf8) == text)
+ continue;
+
+ File.WriteAllText(path, text, m_Utf8);
+ written++;
+ }
+
+ return written;
+ }
+
+ /***************************************************/
+
+ [Description("Deletes .py files in the folders of the written assemblies that are no longer generated, then removes the empty folders. Write calls it when "
+ + "stale output must be cleaned; cleanAssemblies limits it to the assemblies of the requested repositories.")]
+ private static void CleanRedundantExistingFiles(string packageRoot, Dictionary fileTextByPath, ISet cleanAssemblies)
+ {
+ foreach (string assembly in fileTextByPath.Keys.Select(k => k.Split('/')[0]).Distinct())
+ {
+ if (cleanAssemblies != null && !cleanAssemblies.Contains(assembly))
+ continue;
+
+ string folder = Path.Combine(packageRoot, assembly);
+ if (!Directory.Exists(folder))
+ continue;
+
+ foreach (string file in Directory.EnumerateFiles(folder, "*.py", SearchOption.AllDirectories))
+ {
+ string relative = Path.GetRelativePath(packageRoot, file).Replace('\\', '/');
+ if (!fileTextByPath.ContainsKey(relative))
+ File.Delete(file);
+ }
+
+ RemoveEmptyDirectories(folder);
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Recursively deletes the empty folders under a folder. CleanRedundantExistingFiles calls it after removing stale files.")]
+ private static void RemoveEmptyDirectories(string folder)
+ {
+ foreach (string dir in Directory.GetDirectories(folder))
+ {
+ RemoveEmptyDirectories(dir);
+ if (!Directory.EnumerateFileSystemEntries(dir).Any())
+ Directory.Delete(dir);
+ }
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/PyLiteral.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/PyLiteral.cs
new file mode 100644
index 0000000..7483328
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/PyLiteral.cs
@@ -0,0 +1,62 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Text;
+
+namespace PythonSchemaGeneration.Emission
+{
+ //Python source text for C# values, written the way repr()/ast.unparse would write them
+ public static class PyLiteral
+ {
+ /***************************************************/
+
+ [Description("Returns the Python string literal for a text, choosing the quote character and escaping backslashes, control characters and surrogates the "
+ + "way repr would. Used wherever the emitter writes text into generated Python.")]
+ public static string Str(string text)
+ {
+ char quote = text.Contains('\'') && !text.Contains('"') ? '"' : '\'';
+ StringBuilder sb = new StringBuilder();
+ sb.Append(quote);
+
+ foreach (char c in text)
+ {
+ if (c == '\\') sb.Append("\\\\");
+ else if (c == quote) sb.Append('\\').Append(c);
+ else if (c == '\n') sb.Append("\\n");
+ else if (c == '\r') sb.Append("\\r");
+ else if (c == '\t') sb.Append("\\t");
+ else if (c < 0x20 || c == 0x7f) sb.Append("\\x").Append(((int)c).ToString("x2"));
+ else if (char.IsSurrogate(c) || char.IsControl(c)) sb.Append("\\u").Append(((int)c).ToString("x4"));
+ else sb.Append(c);
+ }
+
+ sb.Append(quote);
+ return sb.ToString();
+ }
+
+ /***************************************************/
+
+ [Description("Returns the Python float literal for a double, using float('nan') and float('inf') for the special values and keeping a decimal point. Used for default values.")]
+ public static string Float(double value)
+ {
+ if (double.IsNaN(value)) return "float('nan')";
+ if (double.IsPositiveInfinity(value)) return "float('inf')";
+ if (double.IsNegativeInfinity(value)) return "float('-inf')";
+
+ string text = value.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e");
+ if (!text.Contains('.') && !text.Contains('e'))
+ text += ".0";
+
+ return text;
+ }
+
+ /***************************************************/
+
+ [Description("Returns True or False, the Python spelling of a boolean. Used for default values.")]
+ public static string Bool(bool value)
+ {
+ return value ? "True" : "False";
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/PythonEmitter.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/PythonEmitter.cs
new file mode 100644
index 0000000..74b8cf6
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/PythonEmitter.cs
@@ -0,0 +1,491 @@
+using System.ComponentModel;
+using PythonSchemaGeneration.Mapping;
+using PythonSchemaGeneration.Model;
+using PythonSchemaGeneration.Reporting;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace PythonSchemaGeneration.Emission
+{
+ //Type model to Python source text, deterministic for the same input
+ public class PythonEmitter
+ {
+ /***************************************************/
+
+ private const string Indent = " ";
+ private readonly Reporter m_Reporter;
+ private readonly TypeRegistry m_TypeRegistry;
+
+ /***************************************************/
+
+ [Description("Creates an emitter that writes Python modules using the given type registry to resolve names and the reporter to collect warnings. The "
+ + "generator builds one and reuses it for every type in both render passes.")]
+ public PythonEmitter(TypeRegistry registry, Reporter reporter)
+ {
+ m_TypeRegistry = registry;
+ m_Reporter = reporter;
+ }
+
+ /***************************************************/
+
+ [Description("Renders one language-neutral type model to the text of its Python module (header, imports, class with the __bhom__ marker and fields) and "
+ + "returns it with its properties and the types it references. The generator calls it for every selected type, then again with an early-import "
+ + "test from the CycleDetector so imports that would form a cycle move below the class.")]
+ public RenderedType Render(TypeModel model, Func isSafeForEarlyImport = null, ISet generatedTypes = null)
+ {
+ PyRef pyRef = m_TypeRegistry.Ref(model.Type);
+ List fieldNames = model.Properties.Select(p => Naming.Identifier(p.Name)).Concat(model.GenericParams.Select(g => g.Name)).ToList();
+
+ NameTracker tracker = new NameTracker(model.Type, pyRef.Name, fieldNames) { IsSafeForEarlyImport = isSafeForEarlyImport };
+ RenderedType rendered = new RenderedType { Model = model, Ref = pyRef };
+
+ string classLine = ClassHeader(model, tracker, pyRef, out List baseConstraintNotes);
+ StringBuilder body = new StringBuilder();
+
+ if (model.Description != null)
+ body.Append(Indent).Append(Docstring(model.Description)).Append('\n');
+
+ body.Append(Marker(model, tracker, baseConstraintNotes, rendered, generatedTypes));
+
+ if (model.Kind == TypeKind.Enum)
+ AddEnumMembers(model, body);
+ else if (model.Kind != TypeKind.Interface)
+ AddFields(model, body, tracker, rendered);
+
+ string text = Assemble(model, classLine, body.ToString(), tracker, pyRef);
+ rendered.Text = text;
+ rendered.References = tracker.References;
+ rendered.ClosureReferences = new HashSet(tracker.References.Concat(tracker.MentionedOnly));
+ return rendered;
+ }
+
+ /***************************************************/
+
+ [Description("Builds the Python class line for a type: its base classes (enum, SchemaModel, parent class, interfaces, ABC) and PEP 695 generic parameters "
+ + "with their bounds. Also returns notes for generic constraints that cannot be represented; Render puts them in the marker.")]
+ private string ClassHeader(TypeModel model, NameTracker tracker, PyRef pyRef, out List notes)
+ {
+ notes = new List();
+ List bases = new List();
+ TypeMapper typeMapper = new TypeMapper(m_TypeRegistry, m_Reporter, tracker, model.ClrName) { IsSafeForEarlyImport = true };
+
+ if (model.Kind == TypeKind.Enum)
+ bases.Add(tracker.AddStandardName("bhom_schema._support", model.IsFlags ? "BHoMFlags" : "BHoMEnum"));
+ else
+ {
+ Type baseClass = model.Bases.FirstOrDefault(b => !b.IsInterface);
+ List interfaces = model.Bases.Where(b => b.IsInterface).ToList();
+
+ if (model.Kind != TypeKind.Interface)
+ bases.Add(baseClass != null ? typeMapper.Map(baseClass).Expression : tracker.AddStandardName("bhom_schema._support", "SchemaModel"));
+
+ bases.AddRange(interfaces.Select(i => typeMapper.Map(i).Expression));
+ bool hasAbcBase = interfaces.Count > 0;
+
+ if ((model.Kind == TypeKind.Interface || model.Kind == TypeKind.AbstractClass) && !hasAbcBase)
+ bases.Add(tracker.AddStandardName("abc", "ABC"));
+ }
+
+ string generics = "";
+ if (model.GenericParams.Count > 0)
+ {
+ TypeMapper lazyMapper = new TypeMapper(m_TypeRegistry, m_Reporter, tracker, model.ClrName) { IsSafeForEarlyImport = true, WrapPolymorphic = true };
+ List parts = new List();
+
+ foreach (GenericParamModel param in model.GenericParams)
+ {
+ List usable = param.Constraints.Where(c => m_TypeRegistry.Contains(c)).ToList();
+ if (param.Constraints.Count > usable.Count)
+ notes.Add($"{param.Name}: constraint not representable and dropped");
+
+ if (usable.Count == 0)
+ {
+ parts.Add(param.Name);
+ continue;
+ }
+
+ string bound = lazyMapper.Map(usable[0]).Expression;
+
+ //A PEP 695 bound cannot mention a type parameter, so those arguments become Any
+ if (usable[0].ContainsGenericParameters)
+ {
+ string any = tracker.AddStandardName("typing", "Any");
+ foreach (GenericParamModel other in model.GenericParams)
+ bound = Regex.Replace(bound, $@"\b{Regex.Escape(other.Name)}\b", any);
+ }
+
+ parts.Add($"{param.Name}: {bound}");
+ }
+
+ generics = "[" + string.Join(", ", parts) + "]";
+ }
+
+ return $"class {pyRef.Name}{generics}({string.Join(", ", bases)}):";
+ }
+
+ /***************************************************/
+
+ [Description("Builds the indented __bhom__ dictionary that records the C# name, assembly, namespace, repo, kind, generic parameters, interface members and "
+ + "renamed enum members. Interface members are also added to the rendered properties; Render places the result at the top of every generated "
+ + "class.")]
+ private string Marker(TypeModel model, NameTracker tracker, List notes, RenderedType rendered, ISet generatedTypes)
+ {
+ string any = tracker.AddStandardName("typing", "Any");
+ string classVar = tracker.AddStandardName("typing", "ClassVar");
+
+ List> entries = new List>
+ {
+ new KeyValuePair("clr_name", PyLiteral.Str(model.ClrName)),
+ new KeyValuePair("assembly", PyLiteral.Str(model.Assembly)),
+ new KeyValuePair("namespace", PyLiteral.Str(model.Namespace)),
+ };
+
+ if (model.Repo != null)
+ entries.Add(new KeyValuePair("repo", PyLiteral.Str(model.Repo)));
+
+ entries.Add(new KeyValuePair("kind", PyLiteral.Str(KindText(model.Kind))));
+ if (model.IsFlags)
+ entries.Add(new KeyValuePair("flags", "True"));
+
+ if (model.GenericParams.Count > 0)
+ {
+ string items = string.Join(", ", model.GenericParams.Select(p =>
+ PyLiteral.Str(p.Name) + ": [" + string.Join(", ", p.Constraints.Select(c => PyLiteral.Str(ClrNames.Format(c)))) + "]"));
+
+ entries.Add(new KeyValuePair("generic_params", "{" + items + "}"));
+ }
+
+ if (notes.Count > 0)
+ entries.Add(new KeyValuePair("notes", "[" + string.Join(", ", notes.Select(PyLiteral.Str)) + "]"));
+
+ if (model.Kind == TypeKind.Interface && model.Properties.Count > 0)
+ {
+ List props = new List();
+ foreach (PropertyModel p in model.Properties)
+ {
+ Mention(p.Type, tracker);
+ string entry = "{'type': " + PyLiteral.Str(ClrNames.Format(p.Type));
+
+ if (p.Description != null)
+ entry += ", 'description': " + PyLiteral.Str(p.Description);
+
+ props.Add(PyLiteral.Str(p.Name) + ": " + entry + "}");
+
+ //Interface members are only listed in the marker, but the types they use still need a Python class
+ rendered.Properties.Add(new RenderedProperty
+ {
+ Name = p.Name,
+ PyName = p.Name,
+ ClrType = ClrNames.Format(p.Type),
+ Description = p.Description
+ });
+ }
+
+ entries.Add(new KeyValuePair("properties", "{" + string.Join(", ", props) + "}"));
+ }
+
+ if ((model.Kind == TypeKind.Interface || model.Kind == TypeKind.AbstractClass) && !model.Type.IsGenericTypeDefinition)
+ {
+ string implementers = Implementers(model, generatedTypes);
+ entries.Add(new KeyValuePair("implementers", implementers));
+ }
+
+ if (model.Kind == TypeKind.Enum)
+ {
+ Dictionary renamed = model.EnumMembers
+ .Where(m => Naming.Identifier(m.Name) != m.Name)
+ .ToDictionary(m => Naming.Identifier(m.Name), m => m.Name);
+
+ rendered.RenamedMembers = renamed;
+ if (renamed.Count > 0)
+ entries.Add(new KeyValuePair("renamed_members",
+ "{" + string.Join(", ", renamed.Select(kv => PyLiteral.Str(kv.Key) + ": " + PyLiteral.Str(kv.Value))) + "}"));
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.Append(Indent).Append($"__bhom__: {classVar}[dict[str, {any}]] = {{\n");
+
+ foreach (KeyValuePair kv in entries)
+ sb.Append(Indent).Append(Indent).Append(PyLiteral.Str(kv.Key)).Append(": ").Append(kv.Value).Append(",\n");
+
+ sb.Append(Indent).Append("}\n");
+ return sb.ToString();
+ }
+
+ /***************************************************/
+
+ [Description("Lists the classes that implement an interface or abstract class, as the dictionary text written under 'implementers' in its marker: C# class name to module path " +
+ "below bhom_schema, sorted by name. Only generated classes that are concrete and not generic are listed, so the Polymorphic type of a field accepts exactly the classes that " +
+ "exist in the package, as the published JSON schemas list the subtypes known when they were generated. Used by Marker.")]
+ private string Implementers(TypeModel model, ISet generatedTypes)
+ {
+ IEnumerable candidates = generatedTypes ?? new HashSet(m_TypeRegistry.Types);
+ List> found = candidates
+ .Where(t => !t.IsInterface && !t.IsAbstract && !t.IsEnum && !t.IsGenericType && model.Type.IsAssignableFrom(t))
+ .OrderBy(t => t.FullName, StringComparer.Ordinal)
+ .Select(t => new KeyValuePair(t.FullName, m_TypeRegistry.Ref(t).RelativePath.Replace('/', '.')))
+ .ToList();
+
+ if (found.Count == 0)
+ return "{}";
+
+ string inner = Indent + Indent + Indent;
+ return "{\n" + string.Concat(found.Select(kv => inner + PyLiteral.Str(kv.Key) + ": " + PyLiteral.Str(kv.Value) + ",\n")) + Indent + Indent + "}";
+ }
+
+ /***************************************************/
+
+ [Description("Records the registered types used by an interface member type (looking inside arrays and generic arguments) so they are generated too, "
+ + "without importing them. Marker calls it for interface properties, because their types still need a Python class.")]
+ private void Mention(Type type, NameTracker tracker)
+ {
+ if (type.FullName == "BH.oM.Base.FragmentSet") //written as a list of fragments, see TypeMapper
+ {
+ Type fragment = type.Assembly.GetType("BH.oM.Base.IFragment");
+ if (fragment != null)
+ Mention(fragment, tracker);
+
+ return;
+ }
+
+ if (type.IsArray)
+ Mention(type.GetElementType(), tracker);
+ else if (type.IsGenericType)
+ {
+ foreach (Type argument in type.GetGenericArguments())
+ Mention(argument, tracker);
+ if (m_TypeRegistry.Contains(type))
+ tracker.MentionedOnly.Add(TypeRegistry.Definition(type));
+ }
+ else if (m_TypeRegistry.Contains(type))
+ tracker.MentionedOnly.Add(type);
+ }
+
+ /***************************************************/
+
+ [Description("Returns the marker text for a type kind (abstract, interface, enum, struct or class). Used by the emitter for the __bhom__ marker and by the "
+ + "ManifestWriter so both state the same kind.")]
+ public static string KindText(TypeKind kind)
+ {
+ switch (kind)
+ {
+ case TypeKind.AbstractClass: return "abstract";
+ case TypeKind.Interface: return "interface";
+ case TypeKind.Enum: return "enum";
+ case TypeKind.Struct: return "struct";
+ default: return "class";
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Appends the member lines of an enum class (name = value, with the member description as a comment) to the class body, or a pass line when "
+ + "there are none. Render calls it for enum types.")]
+ private void AddEnumMembers(TypeModel model, StringBuilder body)
+ {
+ body.Append('\n');
+ if (model.EnumMembers.Count == 0)
+ {
+ body.Append(Indent).Append("pass\n");
+ return;
+ }
+
+ foreach (EnumMemberModel member in model.EnumMembers)
+ {
+ if (member.Description != null)
+ body.Append(Indent).Append("# ").Append(member.Description.Replace("\r", " ").Replace("\n", " ")).Append('\n');
+
+ body.Append(Indent).Append(Naming.Identifier(member.Name)).Append(" = ").Append(member.Value).Append('\n');
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Appends one annotated field line per property to the class body: maps the type, adds | None for null defaults, wraps the annotation with "
+ + "ClrType, Quantity, DisplayText and DynamicProperty markers, and writes the default, description and alias through Field. Also fills the "
+ + "rendered property list that the manifest uses; Render calls it for classes and structs.")]
+ private void AddFields(TypeModel model, StringBuilder body, NameTracker tracker, RenderedType rendered)
+ {
+ bool first = true;
+ TypeMapper mapper = new TypeMapper(m_TypeRegistry, m_Reporter, tracker, model.ClrName) { WrapPolymorphic = true };
+ IEnumerable typeParams = model.GenericParams.Select(g => g.Name);
+ DefaultFormatter formatter = new DefaultFormatter(m_TypeRegistry, m_Reporter, tracker, model.ClrName, typeParams);
+
+ foreach (PropertyModel property in model.Properties)
+ {
+ MappedType mapped = mapper.Map(property.Type);
+ DefaultText value = formatter.Format(property, mapped);
+
+ string expression = mapped.Expression;
+
+ //Check if the property is nullable and the type is not a value type, and if so, add " | None" to the type expression
+ bool addNone = value.IsNull && !expression.EndsWith(" | None") && !mapped.IsValueType;
+ if (addNone)
+ expression += " | None";
+
+ string actualClrName = ClrNames.Format(property.Type);
+ string defaultClrName = mapped.DefaultClr;
+ string clrNameOverride = actualClrName != defaultClrName ? actualClrName : null;
+
+ List markers = new List();
+ if (clrNameOverride != null)
+ markers.Add($"{tracker.AddStandardName("bhom_schema._support", "ClrType")}({PyLiteral.Str(clrNameOverride)})");
+
+ if (property.Quantity != null)
+ markers.Add($"{tracker.AddStandardName("bhom_schema._support", "Quantity")}({PyLiteral.Str(property.Quantity.Category)}, {PyLiteral.Str(property.Quantity.Unit ?? "")})");
+
+ if (property.DisplayText != null)
+ markers.Add($"{tracker.AddStandardName("bhom_schema._support", "DisplayText")}({PyLiteral.Str(property.DisplayText)})");
+
+ if (property.IsDynamic)
+ markers.Add($"{tracker.AddStandardName("bhom_schema._support", "DynamicProperty")}()");
+
+ string annotation;
+ if (markers.Count == 0)
+ annotation = expression;
+ else
+ annotation = $"{tracker.AddStandardName("typing", "Annotated")}[{expression}, {string.Join(", ", markers)}]";
+
+ string pyName = Naming.Identifier(property.Name);
+ List fieldArgs = new List();
+ if (value.Kind == DefaultKind.Literal)
+ fieldArgs.Add("default=" + value.Text);
+
+ if (value.Kind == DefaultKind.Factory)
+ fieldArgs.Add("default_factory=" + value.Text);
+
+ if (property.Description != null)
+ fieldArgs.Add("description=" + PyLiteral.Str(property.Description));
+
+ if (pyName != property.Name)
+ fieldArgs.Add("alias=" + PyLiteral.Str(property.Name));
+
+ string assignment = "";
+ if (fieldArgs.Count == 0)
+ assignment = value.Kind == DefaultKind.Literal ? " = " + value.Text : "";
+ else if (fieldArgs.Count == 1 && value.Kind == DefaultKind.Literal)
+ assignment = " = " + value.Text;
+ else
+ assignment = $" = {tracker.AddStandardName("pydantic", "Field")}({string.Join(", ", fieldArgs)})";
+
+ if (first)
+ body.Append('\n');
+ first = false;
+
+ body.Append(Indent).Append(pyName).Append(": ").Append(annotation).Append(assignment).Append('\n');
+
+ rendered.Properties.Add(new RenderedProperty
+ {
+ Name = property.Name,
+ PyName = pyName,
+ ClrType = actualClrName,
+ PyType = expression,
+ HasDefault = value.Kind != DefaultKind.None,
+ Default = value.Kind == DefaultKind.None ? null : (value.Kind == DefaultKind.Factory ? " " + value.Text : value.Text),
+ Description = property.Description,
+ Quantity = property.Quantity,
+ DisplayText = property.DisplayText,
+ IsDynamic = property.IsDynamic
+ });
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Joins the header comment, grouped imports, class line and body into the final module text, with imports of types in a cycle placed after the "
+ + "class followed by model_rebuild. The last step of Render.")]
+ private string Assemble(TypeModel model, string classLine, string body, NameTracker tracker, PyRef pyRef)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.Append($"# Generated by BHoM_PythonSchema from the C# oM type {model.ClrName}. Do not edit by hand.\n");
+ sb.Append("# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved.\n");
+ sb.Append("# Licensed under the GNU Lesser General Public License, version 3.0 or later.\n");
+
+ bool isModel = model.Kind != TypeKind.Enum && model.Kind != TypeKind.Interface;
+ if (isModel)
+ sb.Append("# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false\n");
+
+ sb.Append("\nfrom __future__ import annotations\n\n");
+ List imports = tracker.Imports.ToList();
+
+ //Standard library, then third party, then this package
+ IEnumerable[] groups =
+ [
+ imports.Where(i => !i.IsOm && IsStandardLib(i.Module)),
+ imports.Where(i => !i.IsOm && (i.Module == "pydantic" || i.Module.StartsWith("pydantic."))),
+ imports.Where(i => (!i.IsOm && i.Module.StartsWith("bhom_schema._support")) || (i.IsOm && i.IsSafeForEarlyImport))
+ ];
+
+ bool wrote = false;
+ foreach (IEnumerable group in groups)
+ {
+ List entries = group.ToList();
+ if (entries.Count == 0)
+ continue;
+
+ if (wrote)
+ sb.Append('\n');
+
+ AppendFromImports(sb, entries);
+ wrote = true;
+ }
+
+ sb.Append('\n').Append('\n');
+ sb.Append(classLine).Append('\n');
+ sb.Append(body);
+
+ //Types only used in annotations are imported after the class so circular references cannot break module loading
+ List bottom = imports.Where(i => i.IsOm && !i.IsSafeForEarlyImport).ToList();
+ if (bottom.Count > 0)
+ {
+ sb.Append('\n').Append('\n');
+ AppendFromImports(sb, bottom);
+
+ if (isModel)
+ sb.Append($"\n{pyRef.Name}.model_rebuild()\n");
+ }
+
+ string text = sb.ToString();
+ while (text.EndsWith("\n\n"))
+ text = text.Substring(0, text.Length - 1);
+
+ return text;
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether a module name is one of the Python standard library modules the generator imports from. Assemble uses it to put those imports "
+ + "in the first import group.")]
+ private static bool IsStandardLib(string module)
+ {
+ return module == "abc" || module == "collections.abc" || module == "datetime" || module == "decimal" ||
+ module == "enum" || module == "typing" || module == "uuid";
+ }
+
+ /***************************************************/
+
+ [Description("Appends from-import lines for the given import entries, grouped by module and sorted so the output is deterministic. Assemble calls it for each import group.")]
+ private static void AppendFromImports(StringBuilder sb, IEnumerable imports)
+ {
+ foreach (IGrouping group in imports.GroupBy(i => i.Module).OrderBy(g => g.Key, StringComparer.Ordinal))
+ {
+ IEnumerable names = group
+ .OrderBy(i => i.Name, StringComparer.Ordinal)
+ .Select(i => i.Name == i.Local ? i.Name : $"{i.Name} as {i.Local}");
+ sb.Append($"from {group.Key} import {string.Join(", ", names)}\n");
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Turns a C# description into an indented Python triple-quoted docstring, escaping backslashes and quotes. Render uses it for the class description.")]
+ private static string Docstring(string text)
+ {
+ string escaped = text.Replace("\\", "\\\\").Replace("\"\"\"", "\\\"\\\"\\\"");
+ if (escaped.EndsWith("\"")) escaped = escaped.Substring(0, escaped.Length - 1) + "\\\"";
+ return "\"\"\"" + escaped.Replace("\r\n", "\n").Replace("\n", "\n" + Indent) + "\"\"\"";
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedProperty.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedProperty.cs
new file mode 100644
index 0000000..6c47c4b
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedProperty.cs
@@ -0,0 +1,18 @@
+using PythonSchemaGeneration.Model;
+
+namespace PythonSchemaGeneration.Emission
+{
+ public class RenderedProperty
+ {
+ public string Name;
+ public string PyName;
+ public string ClrType;
+ public string PyType;
+ public bool HasDefault;
+ public string Default;
+ public string Description;
+ public QuantityModel Quantity;
+ public string DisplayText;
+ public bool IsDynamic;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedType.cs b/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedType.cs
new file mode 100644
index 0000000..9e195df
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Emission/RenderedType.cs
@@ -0,0 +1,16 @@
+using PythonSchemaGeneration.Mapping;
+using PythonSchemaGeneration.Model;
+
+namespace PythonSchemaGeneration.Emission
+{
+ public class RenderedType
+ {
+ public TypeModel Model;
+ public PyRef Ref;
+ public string Text;
+ public HashSet References; //imported by the module, used to find import cycles
+ public HashSet ClosureReferences; //everything that has to be generated too
+ public List Properties = new List();
+ public Dictionary RenamedMembers = new Dictionary();
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Extraction/DefaultInstanceReader.cs b/.ci/generation/PythonSchemaGeneration.Core/Extraction/DefaultInstanceReader.cs
new file mode 100644
index 0000000..1a38cec
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Extraction/DefaultInstanceReader.cs
@@ -0,0 +1,169 @@
+using PythonSchemaGeneration.Mapping;
+using PythonSchemaGeneration.Model;
+using PythonSchemaGeneration.Reporting;
+using System.ComponentModel;
+using System.Reflection;
+
+namespace PythonSchemaGeneration.Extraction
+{
+ //Reflection cannot see property initialisers, so defaults are read from freshly created instances
+ public class DefaultInstanceReader
+ {
+ /***************************************************/
+
+ private readonly Reporter m_Reporter;
+ private readonly TypeRegistry m_Registry;
+ private readonly Dictionary m_InstantiableSource = new Dictionary();
+
+ /***************************************************/
+
+ [Description("Creates the reader that finds the default value of each property of a type, using the registry to pick a concrete subtype for abstract types and the " +
+ "reporter for warnings. Created once by the TypeExtractor in the generator's extraction stage.")]
+ public DefaultInstanceReader(TypeRegistry registry, Reporter reporter)
+ {
+ m_Registry = registry;
+ m_Reporter = reporter;
+ }
+
+ /***************************************************/
+
+ [Description("Reads the default value of every readable property by creating two instances of the type (or of its first concrete subtype) and reading each property " +
+ "from both, flagging values such as Guid and DateTime that differ between the two as non-deterministic. Returns an empty dictionary when no instance " +
+ "can be created, so every field is then required; the TypeExtractor calls it for each class and struct to fill the defaults of the type model.")]
+ public Dictionary Read(Type definition, IEnumerable properties)
+ {
+ Dictionary result = new Dictionary();
+ Type source = InstantiableSource(definition);
+
+ if (source == null)
+ return result;
+
+ object first = Instance(source);
+ object second = Instance(source);
+ if (first == null || second == null)
+ return result;
+
+ foreach (PropertyInfo property in properties)
+ {
+ if (property.GetMethod == null || !property.GetMethod.IsPublic)
+ continue;
+ try
+ {
+ PropertyInfo live = Live(property, first);
+ object v1 = live.GetValue(first);
+ object v2 = live.GetValue(second);
+ bool volatileValue = v1 != null && v2 != null && IsVolatileType(v1.GetType()) && !v1.Equals(v2);
+ result[property.Name] = new DefaultValue { Value = v1, NonDeterministic = volatileValue };
+ }
+ catch (Exception e)
+ {
+ m_Reporter.Warn(definition.FullName + "." + property.Name, "default value could not be read (" + (e.InnerException ?? e).Message + "), field is required");
+ }
+ }
+ return result;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the property to read on a live instance: for a property declared on a generic type definition, the same property on the constructed type in " +
+ "the instance's base chain. Used by Read because properties of open generic types cannot be read.")]
+ private static PropertyInfo Live(PropertyInfo property, object instance)
+ {
+ if (!property.DeclaringType.ContainsGenericParameters)
+ return property;
+ Type constructed = instance.GetType();
+ while (constructed != null && !(constructed.IsGenericType && constructed.GetGenericTypeDefinition() == property.DeclaringType))
+ constructed = constructed.BaseType;
+ return constructed?.GetProperty(property.Name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) ?? property;
+ }
+
+ [Description("Tells whether a type (Guid, DateTime, DateTimeOffset) gets a new value on every instantiation. Read uses it to mark such defaults as non-deterministic " +
+ "so they are not written into the generated Python.")]
+ private static bool IsVolatileType(Type type)
+ {
+ return type == typeof(Guid) || type == typeof(DateTime) || type == typeof(DateTimeOffset);
+ }
+
+ /***************************************************/
+
+ [Description("Finds the type to instantiate to read the defaults of a definition: the type itself when it has a usable constructor, otherwise the first concrete " +
+ "subtype in the registry by full name for an abstract class. Returns null for interfaces, enums and types with no instantiable subtype, and caches the " +
+ "answer; used by Read.")]
+ private Type InstantiableSource(Type definition)
+ {
+ if (definition.IsInterface || definition.IsEnum)
+ return null;
+
+ if (m_InstantiableSource.TryGetValue(definition, out Type cached))
+ return cached;
+
+ Type source = null;
+ if (CanInstantiate(definition))
+ source = definition;
+ else if (definition.IsAbstract)
+ {
+ source = m_Registry.Types
+ .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsEnum && t != definition && definition.IsAssignableFrom(Closed(t) ?? t) && CanInstantiate(t))
+ .OrderBy(t => t.FullName, StringComparer.Ordinal)
+ .FirstOrDefault();
+ }
+
+ m_InstantiableSource[definition] = source;
+ return source;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the type itself, or null when it is an open generic type definition that cannot be assigned to another type. InstantiableSource uses it when " +
+ "it searches the registry for a concrete subtype.")]
+ private static Type Closed(Type type)
+ {
+ return type.IsGenericTypeDefinition ? null : type;
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether an instance can be created without arguments: any value type, or a non-abstract class with a public parameterless constructor. Used by " +
+ "InstantiableSource to choose the type to instantiate.")]
+ private static bool CanInstantiate(Type type)
+ {
+ if (type.IsAbstract || type.IsInterface)
+ return false;
+
+ if (type.IsValueType)
+ return true;
+
+ return type.GetConstructor(Type.EmptyTypes) != null;
+ }
+
+ /***************************************************/
+
+ [Description("Creates one instance of a type with Activator, closing a generic type definition over its constraint types (or object) first. Returns null and reports " +
+ "a warning when creation fails; Read calls it twice to compare the values.")]
+ private object Instance(Type type)
+ {
+ try
+ {
+ if (type.IsGenericTypeDefinition)
+ {
+ Type[] args = type.GetGenericArguments().Select(a =>
+ {
+ Type constraint = a.GetGenericParameterConstraints().FirstOrDefault(c => !c.IsGenericType && !c.IsValueType);
+ return constraint ?? typeof(object);
+ }).ToArray();
+
+ type = type.MakeGenericType(args);
+ }
+
+ return Activator.CreateInstance(type);
+ }
+ catch (Exception e)
+ {
+ m_Reporter.Warn(type.FullName, "could not be instantiated to read defaults (" + (e.InnerException ?? e).Message + ")");
+ return null;
+ }
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Extraction/MetadataReader.cs b/.ci/generation/PythonSchemaGeneration.Core/Extraction/MetadataReader.cs
new file mode 100644
index 0000000..301ef8e
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Extraction/MetadataReader.cs
@@ -0,0 +1,111 @@
+using PythonSchemaGeneration.Model;
+using System.ComponentModel;
+using System.Reflection;
+
+namespace PythonSchemaGeneration.Extraction
+{
+ //Reads C# attributes by name so no reference to the assemblies defining them is needed
+ public static class MetadataReader
+ {
+ /***************************************************/
+
+ [Description("Returns the text of the member's System.ComponentModel Description attribute, or null when it is missing or blank. The TypeExtractor uses it to carry " +
+ "the C# descriptions of types, properties and enum members into the type model.")]
+ public static string Description(MemberInfo member)
+ {
+ string text = StringArgument(member, "System.ComponentModel.DescriptionAttribute");
+ return string.IsNullOrWhiteSpace(text) ? null : text;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the text of the member's DisplayText attribute, or null when it has none. The TypeExtractor stores it on the property model so the emitter " +
+ "writes it as a DisplayText annotation.")]
+ public static string DisplayText(MemberInfo member)
+ {
+ return StringArgument(member, "DisplayTextAttribute");
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether the member carries a DynamicProperty attribute, matched by name. The TypeExtractor records it on the property model for the emitter.")]
+ public static bool IsDynamic(MemberInfo member)
+ {
+ return member.CustomAttributes.Any(x => x.AttributeType.Name == "DynamicPropertyAttribute");
+ }
+
+ /***************************************************/
+
+ [Description("Finds the Quantity attribute (any attribute deriving from one named QuantityAttribute) on a member and returns its category, taken from the attribute " +
+ "class name without the Attribute suffix, and its SI unit. Returns null when the member has no quantity; the emitter writes it as a Quantity " +
+ "annotation.")]
+ public static QuantityModel Quantity(MemberInfo member)
+ {
+ foreach (object attribute in SafeAttributes(member))
+ {
+ Type attributeType = attribute.GetType();
+ bool isQuantity = false;
+
+ for (Type b = attributeType; b != null; b = b.BaseType)
+ {
+ if (b.Name == "QuantityAttribute")
+ {
+ isQuantity = true;
+ break;
+ }
+ }
+
+ if (!isQuantity)
+ continue;
+
+ string unit = attributeType.GetProperty("SIUnit")?.GetValue(attribute)?.ToString();
+
+ string categoryName;
+ if (attributeType.Name.EndsWith("Attribute"))
+ {
+ categoryName = attributeType.Name.Substring(0, attributeType.Name.Length - 9);
+ }
+ else
+ {
+ categoryName = attributeType.Name;
+ }
+
+ return new QuantityModel { Category = categoryName, Unit = unit };
+ }
+
+ return null;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the custom attribute instances of a member, or an empty sequence when they cannot be loaded. Used by Quantity so one unreadable attribute does " +
+ "not stop the generator.")]
+ private static IEnumerable SafeAttributes(MemberInfo member)
+ {
+ try { return member.GetCustomAttributes(false); }
+ catch { return Array.Empty(); }
+ }
+
+ /***************************************************/
+
+ [Description("Returns the first constructor argument, when it is a string, of the first attribute on the member whose full name (or plain name when none contains a " +
+ "dot) equals the given name. Reads attribute data by name so the generator needs no reference to the assemblies that define the attributes; used by " +
+ "Description and DisplayText.")]
+ private static string StringArgument(MemberInfo member, string attributeName)
+ {
+ foreach (CustomAttributeData data in member.CustomAttributes)
+ {
+ Type t = data.AttributeType;
+ if (t.FullName == attributeName || (!attributeName.Contains('.') && t.Name == attributeName))
+ {
+ if (data.ConstructorArguments.Count > 0 && data.ConstructorArguments[0].Value is string text)
+ return text;
+ }
+ }
+
+ return null;
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeExtractor.cs b/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeExtractor.cs
new file mode 100644
index 0000000..1beb573
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeExtractor.cs
@@ -0,0 +1,173 @@
+using PythonSchemaGeneration.Loading;
+using PythonSchemaGeneration.Mapping;
+using PythonSchemaGeneration.Model;
+using PythonSchemaGeneration.Reporting;
+using System.ComponentModel;
+using System.Reflection;
+
+namespace PythonSchemaGeneration.Extraction
+{
+ //Reflection to language-neutral type model
+ public class TypeExtractor
+ {
+ /***************************************************/
+
+ private readonly TypeRegistry m_Registry;
+ private readonly Reporter m_Reporter;
+ private readonly DefaultInstanceReader m_Defaults;
+
+ /***************************************************/
+
+ [Description("Creates the extractor with the registry of generated types and the warning reporter, and the reader of default values it uses. The generator creates it once per run.")]
+ public TypeExtractor(TypeRegistry registry, Reporter reporter)
+ {
+ m_Registry = registry;
+ m_Reporter = reporter;
+ m_Defaults = new DefaultInstanceReader(registry, reporter);
+ }
+
+ /***************************************************/
+
+ [Description("Turns one reflected type (or the definition of a generic type) into a language-neutral TypeModel with its names, provenance repository from the " +
+ "assembly description, description, kind, generic parameters, and enum members or bases and properties. Warns when the assembly names no repository; " +
+ "the Generator calls it for every type before the emitter renders it.")]
+ public TypeModel Extract(Type type)
+ {
+ Type def = TypeRegistry.Definition(type);
+ TypeModel model = new TypeModel
+ {
+ Type = def,
+ ClrName = def.FullName,
+ CanonicalName = ClrNames.Format(def),
+ Namespace = def.Namespace,
+ Assembly = def.Assembly.GetName().Name,
+ Repo = BHoMAdapter.AssemblyRepo(def.Assembly),
+ Description = MetadataReader.Description(def),
+ Kind = KindOf(def),
+ IsFlags = def.IsEnum && def.IsDefined(typeof(FlagsAttribute), false),
+ };
+
+ if (model.Repo == null)
+ m_Reporter.Warn(model.ClrName, "assembly has no github.com URL in its Description, repo is unknown");
+
+ foreach (Type arg in def.GetGenericArguments())
+ {
+ GenericParamModel p = new GenericParamModel { Name = arg.Name };
+ p.Constraints.AddRange(arg.GetGenericParameterConstraints().Where(c => c != typeof(ValueType) && c != typeof(object)));
+ model.GenericParams.Add(p);
+ }
+
+ if (model.Kind == TypeKind.Enum)
+ ExtractEnum(def, model);
+ else
+ {
+ ExtractBases(def, model);
+ ExtractProperties(def, model);
+ }
+
+ return model;
+ }
+
+ /***************************************************/
+
+ [Description("Classifies a type as enum, interface, struct, abstract class or class. Used by Extract to set the kind of the type model.")]
+ private static TypeKind KindOf(Type type)
+ {
+ if (type.IsEnum) return TypeKind.Enum;
+ if (type.IsInterface) return TypeKind.Interface;
+ if (type.IsValueType) return TypeKind.Struct;
+ if (type.IsAbstract) return TypeKind.AbstractClass;
+ return TypeKind.Class;
+ }
+
+ /***************************************************/
+
+ [Description("Adds the members of an enum to the model in declaration order, each with its name, description and numeric value as invariant text. Called by Extract for enums.")]
+ private void ExtractEnum(Type def, TypeModel model)
+ {
+ Type underlying = Enum.GetUnderlyingType(def);
+ foreach (FieldInfo field in def.GetFields(BindingFlags.Public | BindingFlags.Static).OrderBy(f => f.MetadataToken))
+ {
+ object rawValue = field.GetRawConstantValue();
+ model.EnumMembers.Add(new EnumMemberModel
+ {
+ Name = field.Name,
+ Description = MetadataReader.Description(field),
+ Value = Convert.ToString(Convert.ChangeType(rawValue, underlying), System.Globalization.CultureInfo.InvariantCulture),
+ });
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Adds to the model the base class and the directly declared interfaces that are generated oM types, dropping interfaces already implied by the base " +
+ "class or another interface, and warns when a non-oM base class is dropped. Called by Extract for non-enum types.")]
+ private void ExtractBases(Type def, TypeModel model)
+ {
+ Type baseType = def.BaseType;
+ if (baseType != null && !def.IsInterface)
+ {
+ if (m_Registry.Contains(baseType))
+ model.Bases.Add(baseType);
+ else if (baseType != typeof(object) && baseType != typeof(ValueType))
+ m_Reporter.Warn(model.ClrName, $"base type {ClrNames.Format(baseType)} is not an oM type and was dropped");
+ }
+
+ //Reflection returns the flattened interface list. Keep only the ones not implied by the base class or by another interface.
+ List allInterfaceTypes = def.GetInterfaces().Where(i => m_Registry.Contains(i)).ToList();
+ HashSet implied = new HashSet();
+
+ if (baseType != null)
+ foreach (Type i in baseType.GetInterfaces())
+ implied.Add(i);
+
+ foreach (Type i in allInterfaceTypes)
+ foreach (Type j in i.GetInterfaces())
+ implied.Add(j);
+
+ model.Bases.AddRange(allInterfaceTypes.Where(i => !implied.Contains(i)).OrderBy(ClrNames.Format, StringComparer.Ordinal));
+ }
+
+ /***************************************************/
+
+ [Description("Adds the public, non-indexed properties declared on the type to the model in metadata order with their default, type, quantity, dynamic flag, " +
+ "description and display text, and skips (with a warning) names starting with an underscore that Pydantic would treat as private. Called by Extract for " +
+ "non-enum types.")]
+ private void ExtractProperties(Type def, TypeModel model)
+ {
+ List properties = def
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+ .Where(p => p.GetIndexParameters().Length == 0 && p.GetMethod != null && p.GetMethod.IsPublic)
+ .OrderBy(p => p.MetadataToken)
+ .ToList();
+
+ Dictionary defaultValuesByPropName = model.Kind == TypeKind.Interface
+ ? new Dictionary()
+ : m_Defaults.Read(def, properties);
+
+ foreach (PropertyInfo property in properties)
+ {
+ if (property.Name.StartsWith("_"))
+ {
+ m_Reporter.Warn(model.ClrName + "." + property.Name, "property name starts with an underscore, Pydantic would treat it as private, skipped");
+ continue;
+ }
+
+ defaultValuesByPropName.TryGetValue(property.Name, out DefaultValue value);
+
+ model.Properties.Add(new PropertyModel
+ {
+ Default = value,
+ Name = property.Name,
+ Type = property.PropertyType,
+ Quantity = MetadataReader.Quantity(property),
+ IsDynamic = MetadataReader.IsDynamic(property),
+ Description = MetadataReader.Description(property),
+ DisplayText = MetadataReader.DisplayText(property),
+ });
+ }
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeSelector.cs b/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeSelector.cs
new file mode 100644
index 0000000..b439119
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Extraction/TypeSelector.cs
@@ -0,0 +1,68 @@
+using BH.oM.Base;
+using PythonSchemaGeneration.Loading;
+using System.ComponentModel;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+namespace PythonSchemaGeneration.Extraction
+{
+ public static class TypeSelector
+ {
+ /***************************************************/
+
+ [Description("Returns the oM types to convert from the loaded assemblies, ordered by full name. The generator's type selection stage, run right after the assemblies " +
+ "are loaded; it feeds the type registry.")]
+ public static List Select(IEnumerable assemblies)
+ {
+ List result = new List();
+ foreach (Assembly assembly in assemblies)
+ {
+ foreach (Type type in AssemblyLoader.SafeGetTypes(assembly))
+ {
+ if (IsSelected(type))
+ result.Add(type);
+ }
+ }
+ return result.OrderBy(x => x.FullName, StringComparer.Ordinal).ToList();
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether a type is converted: it is in an oM namespace and is an enum or an IObject implementer, but it is not a static class, compiler " +
+ "generated, an attribute class, or a constructed generic type. Applies the same rules as the JSON schema generator, and Select uses it.")]
+ public static bool IsSelected(Type type)
+ {
+ if (type.Namespace == null || !BHoMAdapter.IsOmNamespace(type.Namespace))
+ return false;
+
+ if (type.IsAbstract && type.IsSealed)
+ return false;
+
+ if (type.IsDefined(typeof(CompilerGeneratedAttribute), false))
+ return false;
+
+ //An attribute class (a [Description]-like marker) is not data, so it is not part of the schema
+ if (typeof(Attribute).IsAssignableFrom(type))
+ return false;
+
+ if (type.IsGenericType && !type.IsGenericTypeDefinition)
+ return false;
+
+ return type.IsEnum || IsIObject(type);
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether a type implements IObject, checked by assignability and by interface full name. The name check also recognises a recompiled copy of the " +
+ "oM, as the round trip tests use; IsSelected uses it.")]
+ public static bool IsIObject(Type type)
+ {
+ if (typeof(IObject).IsAssignableFrom(type))
+ return true;
+
+ return type.FullName == typeof(IObject).FullName || type.GetInterfaces().Any(i => i.FullName == typeof(IObject).FullName);
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationOptions.cs b/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationOptions.cs
new file mode 100644
index 0000000..47c174d
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationOptions.cs
@@ -0,0 +1,25 @@
+using System.Reflection;
+
+namespace PythonSchemaGeneration
+{
+ public class GenerationOptions
+ {
+ public string AssemblyFolder; //folder with the oM dlls (used when Assemblies is null)
+
+ public List Assemblies; //already loaded assemblies, for tests
+
+ public List Organisations = new List { "BHoM" };
+
+ public string OutputFolder; //the bhom_schema package folder; null to render only
+
+ public List Repos; //Owner/Name of the repositories to convert (from the assembly descriptions); their dependencies are generated too. Null converts every loaded repository.
+
+ public List TypeFilter; //full names of root types; their dependencies are generated too. Null generates everything.
+
+ public string ReleaseVersion; //Major.Minor of a release (for example 10.0): _version.py then holds the release version, and the run fails when it is not the version read
+
+ public string ManifestPath;
+
+ public string ReportPath;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationResult.cs b/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationResult.cs
new file mode 100644
index 0000000..df1e390
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Generation/GenerationResult.cs
@@ -0,0 +1,16 @@
+using PythonSchemaGeneration.Emission;
+using PythonSchemaGeneration.Reporting;
+
+namespace PythonSchemaGeneration
+{
+ public class GenerationResult
+ {
+ public int CyclicTypes;
+ public int FilesWritten;
+ public int AvailableTypes;
+ public string BHoMVersion; //Major.Minor of the assembly version of BHoM.dll (or of another loaded assembly when it is missing), null when no assembly was loaded
+ public List FailedTypes = new List();
+ public Reporter Reporter = new Reporter();
+ public List Types = new List();
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Generator.cs b/.ci/generation/PythonSchemaGeneration.Core/Generator.cs
new file mode 100644
index 0000000..31657bc
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Generator.cs
@@ -0,0 +1,220 @@
+using PythonSchemaGeneration.Emission;
+using PythonSchemaGeneration.Extraction;
+using PythonSchemaGeneration.Loading;
+using PythonSchemaGeneration.Mapping;
+using PythonSchemaGeneration.Model;
+using PythonSchemaGeneration.Reporting;
+using System.ComponentModel;
+using System.Reflection;
+
+namespace PythonSchemaGeneration
+{
+ public static class Generator
+ {
+ /***************************************************/
+
+ [Description("Runs the whole C# to Python conversion: loads the oM assemblies, selects and registers the types, applies the --types and --repos filters, renders the " +
+ "roots and every oM type they reference, finds reference cycles and renders again, then writes the Python files, the manifest and the report that the " +
+ "options ask for. Returns the rendered types, counts and warnings; Program calls it, as do the tests, and it is the orchestrator of the generator " +
+ "pipeline.")]
+ public static GenerationResult Run(GenerationOptions options)
+ {
+ GenerationResult result = new GenerationResult();
+ Reporter reporter = result.Reporter;
+
+ List assemblies = options.Assemblies;
+ if (assemblies == null)
+ assemblies = AssemblyLoader.LoadOmAssemblies(options.AssemblyFolder ?? BHoMAdapter.DefaultAssemblyFolder(), options.Organisations, reporter);
+
+ List selectedTypes = TypeSelector.Select(assemblies);
+ result.AvailableTypes = selectedTypes.Count;
+ TypeRegistry registry = new TypeRegistry();
+
+ foreach (Type type in selectedTypes)
+ registry.Add(type);
+
+ List roots = FilterByRoots(selectedTypes, options.TypeFilter, reporter);
+ HashSet repoAssemblies = null;
+ if (options.Repos != null)
+ {
+ roots = FilterByRepos(roots, options.Repos, reporter, out repoAssemblies);
+ }
+
+ TypeExtractor extractor = new TypeExtractor(registry, reporter);
+ PythonEmitter emitter = new PythonEmitter(registry, reporter);
+
+ //Render the roots and, transitively, every oM type they reference
+ Dictionary renderedTypesByModelType = new Dictionary();
+ Queue queue = new Queue(roots);
+
+ while (queue.Count > 0)
+ {
+ Type type = TypeRegistry.Definition(queue.Dequeue());
+ if (renderedTypesByModelType.ContainsKey(type))
+ continue;
+
+ RenderedType r;
+ try
+ {
+ r = emitter.Render(extractor.Extract(type));
+ }
+ catch (Exception e)
+ {
+ reporter.Warn(type.FullName, "could not be converted: " + e.Message);
+ result.FailedTypes.Add(type.FullName);
+ renderedTypesByModelType[type] = null;
+ continue;
+ }
+
+ renderedTypesByModelType[type] = r;
+ foreach (Type reference in r.ClosureReferences.OrderBy(t => t.FullName, StringComparer.Ordinal))
+ {
+ if (registry.Contains(reference) && !renderedTypesByModelType.ContainsKey(reference))
+ queue.Enqueue(reference);
+ }
+ }
+
+ //Imports of types outside a reference cycle are safe before the class; only cyclic ones stay after it. Render again with that knowledge.
+ Func isCycleRef = CycleDetector.DetectFunction(
+ renderedTypesByModelType.Where(kv => kv.Value != null).ToDictionary(kv => kv.Key, kv => (IEnumerable)kv.Value.References));
+
+ //The interfaces list their implementers, which are exactly the types that are generated
+ HashSet generated = new HashSet(renderedTypesByModelType.Where(kv => kv.Value != null).Select(kv => kv.Key));
+
+ foreach (Type type in renderedTypesByModelType.Keys.ToList())
+ {
+ if (renderedTypesByModelType[type] == null)
+ continue;
+
+ Type self = type;
+ renderedTypesByModelType[type] = emitter.Render(renderedTypesByModelType[type].Model, target => !isCycleRef(self, target), generated);
+ }
+
+ result.Types = renderedTypesByModelType.Values
+ .Where(rendered => rendered != null)
+ .OrderBy(rendered => rendered.Model.ClrName, StringComparer.Ordinal)
+ .ToList();
+
+ result.CyclicTypes = renderedTypesByModelType.Keys
+ .Count(t => renderedTypesByModelType[t] != null && renderedTypesByModelType[t].References.Any(rendered => rendered != t && isCycleRef(t, rendered)));
+
+ result.BHoMVersion = BHoMAdapter.BHoMVersion(assemblies);
+ if (options.ReleaseVersion != null && options.ReleaseVersion != result.BHoMVersion)
+ throw new InvalidOperationException($"The release {options.ReleaseVersion} does not match the BHoM version that was read ({result.BHoMVersion ?? "none"}).");
+
+ if (options.OutputFolder != null)
+ {
+ result.FilesWritten = OutputWriter.Write(options.OutputFolder, result.Types, options.TypeFilter == null, reporter, repoAssemblies);
+
+ //A partial run (--types) does not say what the package is, so it leaves the version file alone
+ if (options.TypeFilter == null)
+ result.FilesWritten += WriteVersionFile(options.OutputFolder, result.BHoMVersion, options.ReleaseVersion != null, reporter);
+ }
+
+ if (options.ManifestPath != null)
+ File.WriteAllText(options.ManifestPath, ManifestWriter.ToJson(result.Types), new System.Text.UTF8Encoding(false));
+
+ if (options.ReportPath != null)
+ File.WriteAllText(options.ReportPath, Report(result), new System.Text.UTF8Encoding(false));
+
+ return result;
+ }
+
+ /***************************************************/
+
+ [Description("Writes bhom_schema/_version.py with the BHoM version the classes come from and the package version: that plus .0 for a release, and that plus .0.dev0 " +
+ "for develop, so the weekly run changes the file only when the BHoM version changes. Returns the number of files written (0 when it is unchanged). Run calls it " +
+ "after the classes are written; setuptools reads the package version from this file.")]
+ private static int WriteVersionFile(string packageRoot, string bhomVersion, bool isRelease, Reporter reporter)
+ {
+ if (bhomVersion == null)
+ {
+ reporter.Warn("No assembly was loaded, so _version.py was not written");
+ return 0;
+ }
+
+ string text = "# Generated by BHoM_PythonSchema from the BHoM assemblies. Do not edit by hand.\n"
+ + "# __bhom_version__ is the BHoM version (Major.Minor) of the classes; __version__ is the version of this package.\n\n"
+ + $"__bhom_version__ = \"{bhomVersion}\"\n"
+ + $"__version__ = \"{bhomVersion}.0{(isRelease ? "" : ".dev0")}\"\n";
+
+ string path = Path.Combine(packageRoot, "_version.py");
+ if (File.Exists(path) && File.ReadAllText(path) == text)
+ return 0;
+
+ Directory.CreateDirectory(packageRoot);
+ File.WriteAllText(path, text, new System.Text.UTF8Encoding(false));
+ return 1;
+ }
+
+ /***************************************************/
+
+ [Description("Keeps the selected types whose full name, base name or formatted name matches one of the requested names (the --types option) and warns about a name " +
+ "that matches nothing. Returns all types when no filter is given; Run uses it to choose the root types.")]
+ private static List FilterByRoots(List selectedTypes, List filter, Reporter reporter)
+ {
+ if (filter == null)
+ return selectedTypes;
+
+ List roots = new List();
+ foreach (string name in filter)
+ {
+ List matches = selectedTypes.Where(t => t.FullName == name || ClrNames.BaseName(t) == name || ClrNames.Format(t) == name).ToList();
+ if (matches.Count == 0)
+ reporter.Warn($"Requested type {name} was not found among the loaded oM types");
+
+ roots.AddRange(matches);
+ }
+ return roots;
+ }
+
+ /***************************************************/
+
+ [Description("Keeps the types of the assemblies that belong to the requested repositories (the --repos option), returns the names of those assemblies for the output " +
+ "writer, and warns about a repository with no loaded assembly. Dependencies in other repositories are still generated; Run uses it so a weekly or " +
+ "on-demand run can convert chosen repositories.")]
+ private static List FilterByRepos(List types, List repos, Reporter reporter, out HashSet assemblies)
+ {
+ HashSet wanted = new HashSet(repos.Select(NormaliseRepo), StringComparer.OrdinalIgnoreCase);
+ List kept = types.Where(t => wanted.Contains(NormaliseRepo(BHoMAdapter.AssemblyRepo(t.Assembly)))).ToList();
+ assemblies = new HashSet(kept.Select(t => t.Assembly.GetName().Name), StringComparer.OrdinalIgnoreCase);
+
+ foreach (string repo in wanted.Where(r => !types.Any(t => NormaliseRepo(BHoMAdapter.AssemblyRepo(t.Assembly)).Equals(r, StringComparison.OrdinalIgnoreCase))))
+ reporter.Warn($"No loaded oM assembly belongs to the requested repository {repo}");
+
+ return kept;
+ }
+
+ /***************************************************/
+
+ [Description("Trims whitespace and slashes from an Owner/Name repository text and turns null into an empty string, so repository names compare equal. Used by FilterByRepos.")]
+ private static string NormaliseRepo(string repo)
+ {
+ return (repo ?? "").Trim().Trim('/');
+ }
+
+ /***************************************************/
+
+ [Description("Builds the text of the report file: counts of available types, generated types, types in cycles, files written and warnings, followed by the info " +
+ "messages and the sorted warnings. Written by Run when a report path is given.")]
+ private static string Report(GenerationResult result)
+ {
+ List lines = new List
+ {
+ $"Types available: {result.AvailableTypes}",
+ $"Types generated: {result.Types.Count}",
+ $"Types in reference cycles: {result.CyclicTypes}",
+ $"Files written: {result.FilesWritten}",
+ $"Warnings: {result.Reporter.Warnings.Count}",
+ ""
+ };
+
+ lines.AddRange(result.Reporter.Infos);
+ lines.Add("");
+ lines.AddRange(result.Reporter.Warnings.OrderBy(w => w, StringComparer.Ordinal));
+ return string.Join("\n", lines) + "\n";
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Loading/AssemblyLoader.cs b/.ci/generation/PythonSchemaGeneration.Core/Loading/AssemblyLoader.cs
new file mode 100644
index 0000000..efe1d4b
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Loading/AssemblyLoader.cs
@@ -0,0 +1,61 @@
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.RegularExpressions;
+
+namespace PythonSchemaGeneration.Loading
+{
+ public static class AssemblyLoader
+ {
+ /***************************************************/
+
+ [Description("Loads every oM assembly of the requested organisations from the BHoM folder, in file name order, and reports each one loaded, skipped or failed. The " +
+ "generator's first stage: only these assemblies are searched for types to convert.")]
+ public static List LoadOmAssemblies(string folder, IEnumerable organisations, Reporting.Reporter reporter)
+ {
+ List result = new List();
+ Regex regex = new Regex(@"oM$");
+
+ foreach (string file in Directory.GetFiles(folder, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
+ {
+ string name = Path.GetFileNameWithoutExtension(file);
+ if (!regex.IsMatch(name) && name != "BHoM")
+ continue;
+
+ try
+ {
+ Assembly loaded = Assembly.LoadFrom(file);
+ if (loaded != null && BHoMAdapter.IsOmAssembly(loaded) && BHoMAdapter.IsInOrg(loaded, organisations))
+ {
+ result.Add(loaded);
+ reporter?.Info($"Loaded assembly {name}");
+ }
+ else
+ reporter?.Info($"Skipped assembly {name} (not an oM assembly of the requested organisations)");
+ }
+ catch (Exception e)
+ {
+ reporter?.Warn($"Could not load assembly {name}: {e.Message}");
+ }
+ }
+
+ return result;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the types of an assembly, or only the types that loaded when some of them fail to load. The TypeSelector uses it so one broken dependency does not stop the run.")]
+ public static Type[] SafeGetTypes(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ return e.Types.Where(x => x != null).ToArray();
+ }
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Loading/BHoMAdapter.cs b/.ci/generation/PythonSchemaGeneration.Core/Loading/BHoMAdapter.cs
new file mode 100644
index 0000000..0de41cc
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Loading/BHoMAdapter.cs
@@ -0,0 +1,117 @@
+using System.ComponentModel;
+using System.Reflection;
+
+namespace PythonSchemaGeneration.Loading
+{
+ //The only place that touches logic referenced from other BHoM repos, so a reference can be swapped in one file
+ public static class BHoMAdapter
+ {
+ /***************************************************/
+
+ [Description("Tells whether a namespace is an oM namespace, by asking the BHoM Engine. Used by the TypeSelector.")]
+ public static bool IsOmNamespace(string ns)
+ {
+ return BH.Engine.Base.Query.IsOmNamespace(ns);
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether an assembly is an oM assembly, by asking the BHoM Engine. Used by the AssemblyLoader.")]
+ public static bool IsOmAssembly(Assembly assembly)
+ {
+ return BH.Engine.Base.Query.IsOmAssembly(assembly);
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether an assembly belongs to one of the given GitHub organisations, by asking the JSON schema engine. The AssemblyLoader uses it to filter " +
+ "assemblies by organisation.")]
+ public static bool IsInOrg(Assembly assembly, IEnumerable organisations)
+ {
+ BH.oM.JsonSchema.ConvertConfig config = new BH.oM.JsonSchema.ConvertConfig { OrganisationsToInclude = organisations.ToList() };
+ return BH.Engine.JsonSchema.Query.IsInOrg(assembly, config);
+ }
+
+ /***************************************************/
+
+ [Description("Returns the path of a type relative to the output package, in the form Assembly/Namespace folders/Name without extension, using the JSON schema " +
+ "engine's layout rule and falling back to the local rule for types the engine does not know. The TypeRegistry uses it to place each generated file.")]
+ public static string RelativeTypePath(Type type)
+ {
+ string path = BH.Engine.JsonSchema.Query.RelativeSchemaId(type);
+ if (path == null)
+ {
+ //the engine only knows types implementing its own copy of IObject
+ return LocalRelativeTypePath(type);
+ }
+
+ return path.EndsWith(".json") ? path.Substring(0, path.Length - 5) : path;
+ }
+
+ /***************************************************/
+
+ [Description("Builds the relative type path locally from the assembly name and the type's namespace, mirroring the JSON schema engine rule. RelativeTypePath uses it " +
+ "for recompiled copies of the oM that the engine does not recognise.")]
+ private static string LocalRelativeTypePath(Type type)
+ {
+ //Same rule as BH.Engine.JsonSchema.Query.RelativeSchemaId: Assembly/Namespace folders (without the first segment)/Name
+ string fullName = type.FullName.Replace("BH.oM.Adapters.", "").Replace("BH.oM.", "");
+ return type.Assembly.GetName().Name + "/" + string.Join("/", fullName.Split('.').Skip(1));
+ }
+
+ /***************************************************/
+
+ [Description("Reads the BHoM version, as Major.Minor (for example 10.0), from the assembly version of BHoM.dll, or of the first loaded assembly by name when BHoM.dll " +
+ "is not among them: every BHoM project sets the same AssemblyVersion, which DevOps raises. Returns null when no assembly is given. The generator writes it into " +
+ "_version.py, so the package version follows the BHoM release.")]
+ public static string BHoMVersion(IEnumerable assemblies)
+ {
+ Assembly source = assemblies.FirstOrDefault(a => a.GetName().Name == "BHoM") ?? assemblies.OrderBy(a => a.GetName().Name, StringComparer.Ordinal).FirstOrDefault();
+ Version version = source?.GetName().Version;
+ return version == null ? null : version.Major + "." + version.Minor;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the folder with the BHoM assemblies: the BHoM folder reported by the engine when it exists, otherwise ProgramData BHoM Assemblies. The " +
+ "generator uses it when no assembly folder is given, and the tests use it to find the oM dlls.")]
+ public static string DefaultAssemblyFolder()
+ {
+ string folder = null;
+ try { folder = BH.Engine.Base.Query.BHoMFolder(); } catch { }
+
+ if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
+ folder = Path.Combine(Environment.GetEnvironmentVariable("ProgramData") ?? "", "BHoM", "Assemblies");
+
+ return folder;
+ }
+
+ /***************************************************/
+
+ [Description("Reads the GitHub repository (Owner/Name) from the github.com address in an assembly's description attribute, or returns null when there is none. The " +
+ "TypeExtractor stores it as the provenance repository of each type, and it drives the --repos filter.")]
+ public static string AssemblyRepo(Assembly assembly)
+ {
+ string description = assembly.GetCustomAttribute()?.Description;
+ if (string.IsNullOrWhiteSpace(description))
+ return null;
+
+ int idx = description.IndexOf("github.com/", StringComparison.OrdinalIgnoreCase);
+ if (idx < 0)
+ return null;
+
+ string rest = description.Substring(idx + "github.com/".Length).Trim().TrimEnd('/');
+ string[] parts = rest.Split('/');
+ if (parts.Length < 2)
+ return null;
+
+ string repo = parts[1];
+ if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
+ repo = repo.Substring(0, repo.Length - 4);
+
+ return parts[0] + "/" + repo;
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/ImportEntry.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/ImportEntry.cs
new file mode 100644
index 0000000..f1eb45a
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/ImportEntry.cs
@@ -0,0 +1,12 @@
+namespace PythonSchemaGeneration.Mapping
+{
+ public class ImportEntry
+ {
+ public string Module;
+ public string Name;
+ public string Local;
+ public bool IsSafeForEarlyImport; //must be imported before the class is defined
+ public bool IsOm; //a generated oM module (imported after the class when not Top)
+ public Type OmType;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/MappedType.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/MappedType.cs
new file mode 100644
index 0000000..03e4b95
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/MappedType.cs
@@ -0,0 +1,9 @@
+namespace PythonSchemaGeneration.Mapping
+{
+ public class MappedType
+ {
+ public string Expression; //Python annotation text
+ public string DefaultClr; //The C# type the Python to C# converter would give back when given Expression
+ public bool IsValueType; //Python side value type: gets "?" when nullable
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/NameTracker.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/NameTracker.cs
new file mode 100644
index 0000000..d810751
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/NameTracker.cs
@@ -0,0 +1,105 @@
+using System.ComponentModel;
+
+namespace PythonSchemaGeneration.Mapping
+{
+
+ //Tracks the names used inside one generated module so imports never collide with fields or each other
+ public class NameTracker
+ {
+ /***************************************************/
+
+ private readonly Type m_Self;
+ private readonly string m_SelfName;
+ private readonly HashSet m_ReservedNames;
+ private readonly Dictionary m_ImportByLocal = new Dictionary();
+ private readonly Dictionary m_ImportBySource = new Dictionary();
+
+ /***************************************************/
+
+ public HashSet References { get; } = new HashSet();
+
+ //Types that must be generated because a marker mentions them, but that are not imported by this module
+ public HashSet MentionedOnly { get; } = new HashSet();
+
+ //Types that can be imported before the class is defined without risking a circular import
+ public Func IsSafeForEarlyImport { get; set; }
+
+ /***************************************************/
+
+ [Description("Creates the tracker for one generated module, given the type being generated, its Python name and the names already used by fields and generic " +
+ "parameters. The PythonEmitter creates one per type.")]
+ public NameTracker(Type self, string selfName, IEnumerable reserved)
+ {
+ m_Self = self;
+ m_SelfName = selfName;
+ m_ReservedNames = new HashSet(reserved) { selfName };
+ }
+
+ /***************************************************/
+
+ public IEnumerable Imports { get { return m_ImportByLocal.Values; } }
+
+ /***************************************************/
+
+ [Description("Registers an import of a standard library or support name, such as typing Any, and returns the local name to write in the annotation. The TypeMapper " +
+ "uses it for Python types that are not generated oM types.")]
+ public string AddStandardName(string module, string name)
+ {
+ return Add(module, name, name, false, false, null);
+ }
+
+ /***************************************************/
+
+ [Description("Registers a reference to a generated oM type, records it among the module's references, and returns the local name to write: the class name for the " +
+ "type itself, otherwise an import that is early when it is safe or needed for the class definition. The TypeMapper calls it for oM types; the " +
+ "references later feed the CycleDetector.")]
+ public string Reference(Type type, PyRef pyRef, bool isSafeForEarlyImport)
+ {
+ Type def = TypeRegistry.Definition(type);
+ References.Add(def);
+
+ if (def == m_Self)
+ return m_SelfName;
+
+ string assembly = pyRef.RelativePath.Split('/')[0];
+ isSafeForEarlyImport |= IsSafeForEarlyImport != null && IsSafeForEarlyImport(def);
+ return Add(pyRef.Module, pyRef.Name, pyRef.Name, isSafeForEarlyImport, true, def, assembly);
+ }
+
+ /***************************************************/
+
+ [Description("Adds an import (module and name) and returns its local name, reusing an existing import of the same source. Resolves clashes with fields and other " +
+ "imports by prefixing the assembly name and then appending underscores. Used by AddStandardName and Reference.")]
+ private string Add(string module, string name, string preferredName, bool isSafeForEarlyImport, bool isOm, Type omType, string assembly = null)
+ {
+ string key = module + ":" + name;
+ if (m_ImportBySource.TryGetValue(key, out ImportEntry existing))
+ {
+ existing.IsSafeForEarlyImport |= isSafeForEarlyImport;
+ return existing.Local;
+ }
+
+ string localName = preferredName;
+ if (IsTaken(localName) && assembly != null)
+ localName = assembly + "_" + preferredName;
+
+ while (IsTaken(localName))
+ localName += "_";
+
+ ImportEntry entry = new ImportEntry { Module = module, Name = name, Local = localName, IsSafeForEarlyImport = isSafeForEarlyImport, IsOm = isOm, OmType = omType };
+ m_ImportByLocal[localName] = entry;
+ m_ImportBySource[key] = entry;
+ return localName;
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether a local name is already reserved or used by an import. Used by Add to keep names unique inside a module.")]
+ private bool IsTaken(string local)
+ {
+ return m_ReservedNames.Contains(local) || m_ImportByLocal.ContainsKey(local);
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/Naming.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/Naming.cs
new file mode 100644
index 0000000..c1a5a84
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/Naming.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel;
+using System.Text;
+
+namespace PythonSchemaGeneration.Mapping
+{
+ public static class Naming
+ {
+ /***************************************************/
+
+ private static readonly HashSet m_Keywords = new HashSet
+ {
+ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
+ "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal",
+ "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"
+ };
+
+ /***************************************************/
+
+ [Description("Tells whether a name is a Python keyword. Identifier uses it to decide whether a trailing underscore is needed.")]
+ public static bool IsKeyword(string name) { return m_Keywords.Contains(name); }
+
+ /***************************************************/
+
+ [Description("Turns any text into a valid Python identifier: invalid characters become underscores, a leading digit gets an underscore in front, and a Python " +
+ "keyword gets a trailing underscore. Used for field, enum member, folder and module names throughout the emission stage.")]
+ public static string Identifier(string name)
+ {
+ StringBuilder sb = new StringBuilder();
+ foreach (char c in name)
+ sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_');
+
+ if (sb.Length == 0 || char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
+ string id = sb.ToString();
+ return IsKeyword(id) ? id + "_" : id;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the Python class name of a C# type, turning the generic arity marker into an underscore (Output`1 becomes Output_1) and making the result a " +
+ "valid identifier. The TypeRegistry uses it to build the module reference of each type.")]
+ public static string TypeName(Type definition)
+ {
+ return Identifier(definition.Name.Replace('`', '_'));
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/PyRef.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/PyRef.cs
new file mode 100644
index 0000000..0483010
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/PyRef.cs
@@ -0,0 +1,9 @@
+namespace PythonSchemaGeneration.Mapping
+{
+ public class PyRef
+ {
+ public string RelativePath; //Assembly/Folders/Name, valid Python names, forward slashes
+ public string Module; //bhom_schema.Assembly.Folders.Name
+ public string Name; //Class name, identical to the last module segment
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeMapper.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeMapper.cs
new file mode 100644
index 0000000..8084802
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeMapper.cs
@@ -0,0 +1,360 @@
+using PythonSchemaGeneration.Model;
+using PythonSchemaGeneration.Reporting;
+using System.ComponentModel;
+
+namespace PythonSchemaGeneration.Mapping
+{
+ //One table: C# type to Python type. Everything that is not the default C# type for the Python type is kept as a ClrType marker.
+ public class TypeMapper
+ {
+ /***************************************************/
+
+ private readonly string m_Context;
+ private readonly Reporter m_Reporter;
+ private readonly TypeRegistry m_Registry;
+ private readonly NameTracker m_NameTracker;
+
+ private static readonly HashSet m_ListTypes = new HashSet
+ {
+ "System.Collections.Generic.List`1", "System.Collections.Generic.IList`1", "System.Collections.Generic.ICollection`1",
+ "System.Collections.Generic.IEnumerable`1", "System.Collections.Generic.IReadOnlyList`1", "System.Collections.Generic.IReadOnlyCollection`1",
+ "System.Collections.ObjectModel.ReadOnlyCollection`1", "System.Collections.ObjectModel.ObservableCollection`1",
+ "System.Collections.ObjectModel.Collection`1", "System.Collections.Generic.Queue`1", "System.Collections.Generic.Stack`1",
+ "System.Collections.Generic.LinkedList`1"
+ };
+
+ private static readonly HashSet m_SetTypes = new HashSet
+ {
+ "System.Collections.Generic.HashSet`1", "System.Collections.Generic.ISet`1", "System.Collections.Generic.SortedSet`1",
+ "System.Collections.Generic.IReadOnlySet`1"
+ };
+
+ private static readonly HashSet m_DictionaryTypes = new HashSet
+ {
+ "System.Collections.Generic.Dictionary`2", "System.Collections.Generic.IDictionary`2", "System.Collections.Generic.SortedDictionary`2",
+ "System.Collections.Generic.SortedList`2", "System.Collections.Generic.IReadOnlyDictionary`2",
+ "System.Collections.Concurrent.ConcurrentDictionary`2", "System.Collections.ObjectModel.ReadOnlyDictionary`2"
+ };
+
+ /***************************************************/
+
+ //When true, referenced oM types are imported before the class is defined (needed for base classes)
+ public bool IsSafeForEarlyImport { get; set; }
+
+ //When true, a type that is an interface or an abstract class is written as Polymorphic[...], so a field of it reads and writes JSON with _t. Not for base classes.
+ public bool WrapPolymorphic { get; set; }
+
+ /***************************************************/
+
+ [Description("Creates the mapper for one generated module, given the registry, reporter, the module's name tracker and the context text used in warnings. The " +
+ "PythonEmitter creates one per type.")]
+ public TypeMapper(TypeRegistry registry, Reporter reporter, NameTracker tracker, string context)
+ {
+ m_Context = context;
+ m_Registry = registry;
+ m_Reporter = reporter;
+ m_NameTracker = tracker;
+ }
+
+ /***************************************************/
+
+ [Description("Maps a C# type to a Python type expression plus the C# type the Python to C# converter would give back for it, handling generic parameters, nullables, " +
+ "primitives, arrays, oM types, delegates and collections, and mapping anything else to Any with a warning. The central step of the type mapping stage, " +
+ "called by the emitter for every property and base.")]
+ public MappedType Map(Type type)
+ {
+ if (type.IsGenericParameter)
+ return new MappedType { Expression = type.Name, DefaultClr = type.Name };
+
+ Type underlyingType = Nullable.GetUnderlyingType(type);
+ if (underlyingType != null)
+ {
+ MappedType inner = Map(underlyingType);
+ return new MappedType { Expression = inner.Expression + " | None", DefaultClr = inner.DefaultClr + "?" };
+ }
+
+ string fullName = type.FullName;
+ if (fullName == "BH.oM.Base.FragmentSet")
+ {
+ Type fragment = type.Assembly.GetType("BH.oM.Base.IFragment");
+ MappedType item = fragment != null && m_Registry.Contains(fragment) ? Map(fragment) : Any();
+ return new MappedType { Expression = $"list[{item.Expression}]", DefaultClr = ListOf(item.DefaultClr) };
+ }
+
+ MappedType primitive = PrimitiveType(type);
+ if (primitive != null)
+ return primitive;
+
+ if (type.IsArray)
+ return ArrayType(type);
+
+ if (m_Registry.Contains(type))
+ return OmType(type);
+
+ if (type.IsEnum)
+ return UnsupportedType(type);
+
+ if (typeof(Delegate).IsAssignableFrom(type))
+ {
+ return new MappedType
+ {
+ DefaultClr = "System.Delegate",
+ //A delegate cannot be written to JSON, so it is left out of the JSON Schema
+ Expression = $"{m_NameTracker.AddStandardName("pydantic.json_schema", "SkipJsonSchema")}[{m_NameTracker.AddStandardName("collections.abc", "Callable")}[..., {m_NameTracker.AddStandardName("typing", "Any")}]]",
+ };
+ }
+
+ MappedType collection = CollectionType(type);
+ if (collection != null)
+ return collection;
+
+ return UnsupportedType(type);
+ }
+
+ /***************************************************/
+
+ [Description("Returns the mapping to Python's Any (importing it through the name tracker) with object as the C# type. The fallback of Map for types with no Python equivalent.")]
+ private MappedType Any()
+ {
+ return new MappedType { Expression = m_NameTracker.AddStandardName("typing", "Any"), DefaultClr = "object" };
+ }
+
+ /***************************************************/
+
+ [Description("Maps a type with no Python equivalent to Any and warns about it, except for object, System.Type and System.Enum, which are mapped silently. Used by Map.")]
+ private MappedType UnsupportedType(Type type)
+ {
+ if (type != typeof(object) && type.FullName != "System.Type" && type.FullName != "System.Enum")
+ m_Reporter.Warn(m_Context, $"type {ClrNames.Format(type)} has no Python equivalent and is mapped to Any");
+
+ return Any();
+ }
+
+ /***************************************************/
+
+ [Description("Returns the C# type text of a List of the given item type. Used to give the default C# type of Python lists.")]
+ private static string ListOf(string item)
+ {
+ return $"System.Collections.Generic.List<{item}>";
+ }
+
+ /***************************************************/
+
+ [Description("Maps the built-in C# types (bool, integers, floats, decimal, char, string, object, Guid, date and time types, Color, Type, Enum) to Python types and " +
+ "their default C# types, or returns null when the type is not one of them. Used by Map.")]
+ private MappedType PrimitiveType(Type type)
+ {
+ if (type == typeof(bool))
+ return new MappedType { Expression = "bool", DefaultClr = "bool", IsValueType = true };
+
+ if (type == typeof(byte) || type == typeof(sbyte) || type == typeof(short) || type == typeof(ushort) ||
+ type == typeof(int) || type == typeof(uint) || type == typeof(long) || type == typeof(ulong))
+ return new MappedType { Expression = "int", DefaultClr = "int", IsValueType = true };
+
+ if (type == typeof(float) || type == typeof(double))
+ return new MappedType { Expression = "float", DefaultClr = "double", IsValueType = true };
+
+ if (type == typeof(decimal))
+ return new MappedType { Expression = m_NameTracker.AddStandardName("decimal", "Decimal"), DefaultClr = "decimal", IsValueType = true };
+
+ if (type == typeof(char) || type == typeof(string))
+ return new MappedType { Expression = "str", DefaultClr = "string", IsValueType = type == typeof(char) };
+
+ if (type == typeof(object))
+ return Any();
+
+ if (type == typeof(Guid))
+ return new MappedType { Expression = m_NameTracker.AddStandardName("uuid", "UUID"), DefaultClr = "System.Guid", IsValueType = true };
+
+ if (type == typeof(DateTime) || type == typeof(DateTimeOffset))
+ return new MappedType { Expression = m_NameTracker.AddStandardName("datetime", "datetime"), DefaultClr = "System.DateTime", IsValueType = true };
+
+ if (type == typeof(TimeSpan))
+ return new MappedType { Expression = m_NameTracker.AddStandardName("datetime", "timedelta"), DefaultClr = "System.TimeSpan", IsValueType = true };
+
+ if (type.FullName == "System.Drawing.Color")
+ return new MappedType { Expression = m_NameTracker.AddStandardName("bhom_schema._support", "Color"), DefaultClr = "System.Drawing.Color", IsValueType = true };
+
+ if (type.FullName == "System.Type" || type.FullName == "System.Enum")
+ return Any();
+
+ return null;
+ }
+
+ /***************************************************/
+
+ [Description("Maps a C# array to nested Python lists, one level per dimension of the array rank. Used by Map.")]
+ private MappedType ArrayType(Type type)
+ {
+ MappedType item = Map(type.GetElementType());
+ string expr = item.Expression;
+ string clr = item.DefaultClr;
+
+ for (int i = 0; i < type.GetArrayRank(); i++)
+ {
+ expr = $"list[{expr}]";
+ clr = ListOf(clr);
+ }
+
+ return new MappedType { Expression = expr, DefaultClr = clr };
+ }
+
+ /***************************************************/
+
+ [Description("Maps a generated oM type to its imported Python class name, with its mapped type arguments in brackets when it is generic, and marks enums and structs " +
+ "as value types. Registers the reference through the name tracker; used by Map.")]
+ private MappedType OmType(Type type)
+ {
+ PyRef pyRef = m_Registry.Ref(type);
+ string local = m_NameTracker.Reference(type, pyRef, IsSafeForEarlyImport);
+ Type def = TypeRegistry.Definition(type);
+ bool valueType = def.IsEnum || def.IsValueType;
+
+ if (!type.IsGenericType)
+ return new MappedType { Expression = Polymorphic(local, def), DefaultClr = ClrNames.Format(type), IsValueType = valueType };
+
+ List args = type.GetGenericArguments().Select(Map).ToList();
+ return new MappedType
+ {
+ Expression = $"{local}[{string.Join(", ", args.Select(a => a.Expression))}]",
+ DefaultClr = $"{ClrNames.BaseName(type)}<{string.Join(", ", args.Select(a => a.DefaultClr))}>",
+ IsValueType = valueType
+ };
+ }
+
+ /***************************************************/
+
+ [Description("Writes the class name of an interface or abstract class as Polymorphic[Name] when the mapper is used for a field, so that field reads and writes JSON the way " +
+ "the published JSON schemas describe (with _t, choosing among the implementers listed in the interface's marker). Any other type, and every base class, stays as it is. Used by OmType.")]
+ private string Polymorphic(string local, Type definition)
+ {
+ if (!WrapPolymorphic || definition.IsEnum || definition.IsGenericTypeDefinition || !(definition.IsInterface || definition.IsAbstract))
+ return local;
+
+ return $"{m_NameTracker.AddStandardName("bhom_schema._support", "Polymorphic")}[{local}]";
+ }
+
+ /***************************************************/
+
+ [Description("Maps generic and non-generic collection types to Python list, set, dict or tuple (a set or dictionary with unhashable keys becomes a list), including " +
+ "any other IEnumerable of T as a list. Returns null for a type that is not a collection; used by Map.")]
+ private MappedType CollectionType(Type type)
+ {
+ if (type.IsGenericType)
+ {
+ Type def = type.GetGenericTypeDefinition();
+ string name = def.FullName;
+ Type[] args = type.GetGenericArguments();
+
+ if (m_ListTypes.Contains(name))
+ {
+ MappedType itemType = Map(args[0]);
+ return new MappedType
+ {
+ Expression = $"list[{itemType.Expression}]",
+ DefaultClr = ListOf(itemType.DefaultClr)
+ };
+ }
+
+ if (m_SetTypes.Contains(name))
+ {
+ MappedType itemType = Map(args[0]);
+ if (IsHashable(args[0]))
+ return new MappedType
+ {
+ Expression = $"set[{itemType.Expression}]",
+ DefaultClr = $"System.Collections.Generic.HashSet<{itemType.DefaultClr}>"
+ };
+
+ return new MappedType { Expression = $"list[{itemType.Expression}]", DefaultClr = ListOf(itemType.DefaultClr) };
+ }
+
+ if (m_DictionaryTypes.Contains(name))
+ {
+ MappedType keyType = Map(args[0]);
+ MappedType valueType = Map(args[1]);
+
+ if (IsHashable(args[0]))
+ return new MappedType
+ {
+ Expression = $"dict[{keyType.Expression}, {valueType.Expression}]",
+ DefaultClr = $"System.Collections.Generic.Dictionary<{keyType.DefaultClr}, {valueType.DefaultClr}>"
+ };
+
+ return new MappedType
+ {
+ Expression = $"list[tuple[{keyType.Expression}, {valueType.Expression}]]",
+ DefaultClr = ListOf($"System.Tuple<{keyType.DefaultClr}, {valueType.DefaultClr}>")
+ };
+ }
+
+ if (name.StartsWith("System.Tuple`") || name.StartsWith("System.ValueTuple`") || name == "System.Collections.Generic.KeyValuePair`2")
+ {
+ List itemTypes = args.Select(Map).ToList();
+ return new MappedType
+ {
+ Expression = $"tuple[{string.Join(", ", itemTypes.Select(i => i.Expression))}]",
+ DefaultClr = $"System.Tuple<{string.Join(", ", itemTypes.Select(i => i.DefaultClr))}>"
+ };
+ }
+
+ Type enumerable = type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
+ if (enumerable != null && type != typeof(string))
+ {
+ MappedType itemType = Map(enumerable.GetGenericArguments()[0]);
+ return new MappedType
+ {
+ Expression = $"list[{itemType.Expression}]",
+ DefaultClr = ListOf(itemType.DefaultClr)
+ };
+ }
+
+ return null;
+ }
+
+ string fullName = type.FullName;
+ if (fullName == "System.Collections.IDictionary" || fullName == "System.Collections.Hashtable")
+ {
+ string any = m_NameTracker.AddStandardName("typing", "Any");
+ return new MappedType
+ {
+ Expression = $"dict[{any}, {any}]",
+ DefaultClr = "System.Collections.Generic.Dictionary"
+ };
+ }
+
+ if (fullName == "System.Collections.IEnumerable" || fullName == "System.Collections.IList" || fullName == "System.Collections.ArrayList" || fullName == "System.Collections.ICollection")
+ return new MappedType
+ {
+ Expression = $"list[{m_NameTracker.AddStandardName("typing", "Any")}]",
+ DefaultClr = ListOf("object")
+ };
+
+ return null;
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether values of a type can be Python set members or dictionary keys: primitives, strings, decimals, enums, object, Guid, date and time types " +
+ "and tuples of those. Pydantic models are not hashable, so CollectionType turns such sets and dictionaries into lists.")]
+ public static bool IsHashable(Type type)
+ {
+ Type underlying = Nullable.GetUnderlyingType(type);
+ if (underlying != null)
+ type = underlying;
+
+ if (type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || type.IsEnum || type == typeof(object))
+ return true;
+
+ if (type == typeof(Guid) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan))
+ return true;
+
+ if (type.IsGenericType && type.FullName != null && type.FullName.StartsWith("System.Tuple`"))
+ return type.GetGenericArguments().All(IsHashable);
+
+ return false;
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeRegistry.cs b/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeRegistry.cs
new file mode 100644
index 0000000..025147c
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Mapping/TypeRegistry.cs
@@ -0,0 +1,64 @@
+using PythonSchemaGeneration.Loading;
+using System.ComponentModel;
+
+namespace PythonSchemaGeneration.Mapping
+{
+ //The set of C# types that get a Python class, and where each one lives
+ public class TypeRegistry
+ {
+ /***************************************************/
+
+ public const string RootPackage = "bhom_schema";
+ private readonly HashSet m_Types = new HashSet();
+ private readonly Dictionary m_Refs = new Dictionary();
+
+ /***************************************************/
+
+ public IEnumerable Types { get { return m_Types; } }
+
+ /***************************************************/
+
+ [Description("Returns the generic type definition of a constructed generic type, or the type itself. Used wherever a generic type must be treated as one generated class.")]
+ public static Type Definition(Type type)
+ {
+ return type.IsGenericType ? type.GetGenericTypeDefinition() : type;
+ }
+
+ /***************************************************/
+
+ [Description("Adds the definition of a type to the set of types that get a Python class. The Generator fills the registry with the selected types before extraction starts.")]
+ public void Add(Type type) { m_Types.Add(Definition(type)); }
+
+ /***************************************************/
+
+ [Description("Tells whether the type, or its generic definition, gets a Python class. The extractor and type mapper use it to decide whether a type is a generated oM type.")]
+ public bool Contains(Type type) { return m_Types.Contains(Definition(type)); }
+
+ /***************************************************/
+
+ [Description("Returns the Python name, relative path and module of the class generated for a type, built from the same path layout as the JSON schema and cached per " +
+ "type. Used by the mapper to write imports and by the output writer to place files.")]
+ public PyRef Ref(Type type)
+ {
+ Type def = Definition(type);
+ if (m_Refs.TryGetValue(def, out PyRef pyRef))
+ return pyRef;
+
+ string path = BHoMAdapter.RelativeTypePath(def);
+ string[] segments = path.Split('/').Select(Naming.Identifier).ToArray();
+ segments[segments.Length - 1] = Naming.TypeName(def);
+
+ pyRef = new PyRef
+ {
+ Name = segments[segments.Length - 1],
+ RelativePath = string.Join("/", segments),
+ Module = RootPackage + "." + string.Join(".", segments),
+ };
+
+ m_Refs[def] = pyRef;
+ return pyRef;
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/ClrNames.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/ClrNames.cs
new file mode 100644
index 0000000..42b5b03
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/ClrNames.cs
@@ -0,0 +1,67 @@
+using System.ComponentModel;
+
+namespace PythonSchemaGeneration.Model
+{
+ //Canonical text form of a C# type, shared with the Python to C# converter (which rebuilds the same strings)
+ public static class ClrNames
+ {
+ /***************************************************/
+
+ private static readonly Dictionary m_Aliases = new Dictionary
+ {
+ { typeof(bool), "bool" }, { typeof(byte), "byte" }, { typeof(sbyte), "sbyte" },
+ { typeof(short), "short" }, { typeof(ushort), "ushort" }, { typeof(int), "int" },
+ { typeof(uint), "uint" }, { typeof(long), "long" }, { typeof(ulong), "ulong" },
+ { typeof(float), "float" }, { typeof(double), "double" }, { typeof(decimal), "decimal" },
+ { typeof(char), "char" }, { typeof(string), "string" }, { typeof(object), "object" },
+ };
+
+ /***************************************************/
+
+ [Description("Writes a C# type as canonical text: keyword aliases for built-in types, a question mark for nullables, bracket syntax for arrays and angle brackets " +
+ "for generic arguments. The same text is rebuilt by the Python to C# converter, so it is used for type model names, manifests and mapped C# types.")]
+ public static string Format(Type type)
+ {
+ if (type.IsGenericParameter)
+ return type.Name;
+
+ Type nullable = Nullable.GetUnderlyingType(type);
+ if (nullable != null)
+ return Format(nullable) + "?";
+
+ if (type.IsArray)
+ {
+ int rank = type.GetArrayRank();
+ return Format(type.GetElementType()) + "[" + new string(',', rank - 1) + "]";
+ }
+
+ if (m_Aliases.TryGetValue(type, out string alias))
+ return alias;
+
+ string name = BaseName(type);
+ if (type.IsGenericType)
+ {
+ Type[] args = type.GetGenericArguments();
+ return name + "<" + string.Join(", ", args.Select(Format)) + ">";
+ }
+ return name;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the full name of a type without generic arity or arguments and with dots for nested types. Used by Format, the type filter and the mapper's default C# types.")]
+ public static string BaseName(Type type)
+ {
+ Type def = type.IsGenericType ? type.GetGenericTypeDefinition() : type;
+ string fullName = def.FullName ?? ((def.Namespace == null ? "" : def.Namespace + ".") + def.Name);
+
+ int tickIndex = fullName.IndexOf('`');
+ if (tickIndex >= 0)
+ fullName = fullName.Substring(0, tickIndex);
+
+ return fullName.Replace('+', '.');
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/DefaultValue.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/DefaultValue.cs
new file mode 100644
index 0000000..846679c
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/DefaultValue.cs
@@ -0,0 +1,8 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class DefaultValue
+ {
+ public object Value;
+ public bool NonDeterministic;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/EnumMemberModel.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/EnumMemberModel.cs
new file mode 100644
index 0000000..376821d
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/EnumMemberModel.cs
@@ -0,0 +1,9 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class EnumMemberModel
+ {
+ public string Name;
+ public string Value; //decimal text of the underlying value
+ public string Description;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/GenericParamModel.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/GenericParamModel.cs
new file mode 100644
index 0000000..a14351d
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/GenericParamModel.cs
@@ -0,0 +1,8 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class GenericParamModel
+ {
+ public string Name;
+ public List Constraints = new List();
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/PropertyModel.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/PropertyModel.cs
new file mode 100644
index 0000000..2bd6c8b
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/PropertyModel.cs
@@ -0,0 +1,13 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class PropertyModel
+ {
+ public string Name;
+ public Type Type;
+ public string Description;
+ public QuantityModel Quantity;
+ public string DisplayText;
+ public bool IsDynamic;
+ public DefaultValue Default; //null when no default could be discovered, in which case the field is required
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/QuantityModel.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/QuantityModel.cs
new file mode 100644
index 0000000..2d5669f
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/QuantityModel.cs
@@ -0,0 +1,8 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class QuantityModel
+ {
+ public string Category;
+ public string Unit;
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/TypeKind.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/TypeKind.cs
new file mode 100644
index 0000000..5b1b6cd
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/TypeKind.cs
@@ -0,0 +1,4 @@
+namespace PythonSchemaGeneration.Model
+{
+ public enum TypeKind { Class, AbstractClass, Interface, Enum, Struct }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Model/TypeModel.cs b/.ci/generation/PythonSchemaGeneration.Core/Model/TypeModel.cs
new file mode 100644
index 0000000..b4c96d4
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Model/TypeModel.cs
@@ -0,0 +1,19 @@
+namespace PythonSchemaGeneration.Model
+{
+ public class TypeModel
+ {
+ public Type Type; //generic type definition for generic types
+ public string ClrName; //Type.FullName, with the backtick arity for generics
+ public string CanonicalName; //Readable name, for example BH.oM.Base.BHoMGroup
+ public string Namespace;
+ public string Assembly;
+ public string Repo;
+ public string Description;
+ public TypeKind Kind;
+ public bool IsFlags;
+ public List GenericParams = new List();
+ public List Bases = new List(); //class base first, then interfaces
+ public List Properties = new List();
+ public List EnumMembers = new List();
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/PythonSchemaGeneration.Core.csproj b/.ci/generation/PythonSchemaGeneration.Core/PythonSchemaGeneration.Core.csproj
new file mode 100644
index 0000000..8e6528a
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/PythonSchemaGeneration.Core.csproj
@@ -0,0 +1,31 @@
+
+
+
+
+
+ net8.0
+ enable
+ disable
+ PythonSchemaGeneration
+
+
+
+
+
+
+
+
+ $(BHoMAssemblies)\BHoM_Engine.dll
+ false
+
+
+ $(BHoMAssemblies)\JsonSchema_oM.dll
+ false
+
+
+ $(BHoMAssemblies)\JsonSchema_Engine.dll
+ false
+
+
+
+
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Reporting/Reporter.cs b/.ci/generation/PythonSchemaGeneration.Core/Reporting/Reporter.cs
new file mode 100644
index 0000000..c54fc96
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Reporting/Reporter.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel;
+
+namespace PythonSchemaGeneration.Reporting
+{
+ public class Reporter
+ {
+ public List Warnings { get; } = new List();
+ public List Infos { get; } = new List();
+
+ /***************************************************/
+
+ [Description("Records a warning message without a context. The generator stages use it for problems that concern the whole run, and the WarningBaseline later compares the kinds of the warnings with the committed list.")]
+ public void Warn(string message) { Warnings.Add(message); }
+
+ /***************************************************/
+
+ [Description("Records a warning message prefixed with the context, usually the type or member it concerns, in square brackets. The extraction and mapping stages use it for every conversion problem, and it ends up in the report and the warning baseline check.")]
+ public void Warn(string context, string message) { Warnings.Add($"[{context}] {message}"); }
+
+ /***************************************************/
+
+ [Description("Records an informational message, for example that an assembly was loaded or skipped. The messages are listed in the generation report.")]
+ public void Info(string message) { Infos.Add(message); }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Core/Reporting/WarningBaseline.cs b/.ci/generation/PythonSchemaGeneration.Core/Reporting/WarningBaseline.cs
new file mode 100644
index 0000000..870894a
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Core/Reporting/WarningBaseline.cs
@@ -0,0 +1,64 @@
+using System.ComponentModel;
+using System.Text.RegularExpressions;
+
+namespace PythonSchemaGeneration.Reporting
+{
+ //The known kinds of warning. A warning kind is the message without its [context] and without the names and numbers in it, so the same problem in
+ //another type is the same kind. A run fails on a kind that is not in the baseline: a change in BHoM that the generator does not handle yet.
+ public static class WarningBaseline
+ {
+ /***************************************************/
+
+ private static readonly Regex m_Context = new Regex(@"^\[[^\]]*\]\s*");
+ private static readonly Regex m_QualifiedName = new Regex(@"\b[A-Za-z_]\w*(?:\.[A-Za-z_][\w`]*)+(?:<[^>]*>)?(?:\[[,\s]*\])*");
+ private static readonly Regex m_Number = new Regex(@"(?");
+ return m_Number.Replace(text, "").Trim();
+ }
+
+ /***************************************************/
+
+ [Description("Returns the distinct kinds of the given warnings in ordinal order. Used by Write and Compare, and by Program to report the number of kinds.")]
+ public static List Kinds(IEnumerable warnings)
+ {
+ return warnings.Select(Kind).Distinct(StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList();
+ }
+
+ /***************************************************/
+
+ [Description("Reads the committed list of known warning kinds (one per line, blank lines and lines starting with # ignored). Program passes it to Compare to fail a run that has a kind not in the list.")]
+ public static List Read(string path)
+ {
+ return File.ReadAllLines(path).Select(l => l.Trim()).Where(l => l.Length > 0 && !l.StartsWith("#")).ToList();
+ }
+
+ /***************************************************/
+
+ [Description("Writes the kinds of the given warnings, with a header, to the baseline file. Program does this for --write-baseline, after a new kind has been reviewed.")]
+ public static void Write(string path, IEnumerable warnings)
+ {
+ string header = "# Known warning kinds of the generator. A kind that is not listed fails the run.\n# Update with --write-baseline after reviewing a new kind.\n";
+ File.WriteAllText(path, header + string.Join("\n", Kinds(warnings)) + "\n", new System.Text.UTF8Encoding(false));
+ }
+
+ /***************************************************/
+
+ [Description("Compares the warning kinds of a run with the baseline: returns the new kinds, which fail the run, and the listed kinds that no longer occur, which Program reports so the baseline can be trimmed.")]
+ public static (List New, List Gone) Compare(IEnumerable warnings, IEnumerable baseline)
+ {
+ List found = Kinds(warnings);
+ HashSet known = new HashSet(baseline, StringComparer.Ordinal);
+ return (found.Where(k => !known.Contains(k)).ToList(), known.Where(k => !found.Contains(k)).OrderBy(k => k, StringComparer.Ordinal).ToList());
+ }
+
+ /***************************************************/
+ }
+}
diff --git a/.ci/generation/PythonSchemaGeneration.Patcher/FilePatcher.cs b/.ci/generation/PythonSchemaGeneration.Patcher/FilePatcher.cs
new file mode 100644
index 0000000..1583625
--- /dev/null
+++ b/.ci/generation/PythonSchemaGeneration.Patcher/FilePatcher.cs
@@ -0,0 +1,804 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+using System.ComponentModel;
+using System.Globalization;
+
+namespace PythonSchemaGeneration.Patcher
+{
+ //Changes one existing C# file member by member. Roslyn finds the members; the edits are applied to the original text,
+ //so everything that is not changed (comments, blank lines, attributes the Python does not know about) stays as it is.
+ public class FilePatcher
+ {
+ /***************************************************/
+
+ private readonly string m_Source;
+ private readonly SourceText m_Text;
+ private readonly CompilationUnitSyntax m_Root;
+ private readonly string m_NewLine;
+ private readonly TextEdits m_Edits = new TextEdits();
+ private readonly List m_Removed = new List();
+ private readonly List m_Notes = new List();
+
+ /***************************************************/
+
+ [Description("Parses the C# source of one file with Roslyn and remembers its text and line-break style. Created only by Patch, so every edit of a file works on one "
+ + "parsed copy of the original.")]
+ private FilePatcher(string source)
+ {
+ m_Source = source;
+ m_Text = SourceText.From(source);
+ m_Root = CSharpSyntaxTree.ParseText(m_Text).GetCompilationUnitRoot();
+ m_NewLine = source.Contains("\r\n") ? "\r\n" : "\n";
+ }
+
+ /***************************************************/
+
+ [Description("Applies the member-level changes of one file job to the source of an existing C# file and returns the new text with notes for changes that could not "
+ + "be made. The patcher's entry point: Program calls it once per file of the job that py2cs writes, and only the changed members are touched.")]
+ public static PatchResult Patch(string source, FileJob job)
+ {
+ return new FilePatcher(source).Apply(job);
+ }
+
+ /***************************************************/
+
+ [Description("Finds the type named in the job and applies each change to it as an edit on the original text, then adds new properties or enum members and any "
+ + "missing usings. Throws when the file does not declare the type; unknown change kinds are noted and skipped.")]
+ private PatchResult Apply(FileJob job)
+ {
+ BaseTypeDeclarationSyntax type = FindType(job.Type, job.Namespace);
+ if (type == null)
+ throw new InvalidOperationException($"the file does not declare {job.Type}");
+
+ foreach (Change change in job.Changes)
+ {
+ switch (change.Kind)
+ {
+ case "addProperty":
+ case "addEnumMember":
+ break;
+ case "removeProperty":
+ RemoveProperty(type, change);
+ break;
+ case "setPropertyType":
+ SetPropertyType(type, change);
+ break;
+ case "setPropertyInitialiser":
+ SetPropertyInitialiser(type, change);
+ break;
+ case "setDescription":
+ SetDescription(type, change);
+ break;
+ case "setAttributes":
+ SetAttributes(type, change);
+ break;
+ case "setBases":
+ SetBases(type, change);
+ break;
+ case "removeEnumMember":
+ RemoveEnumMember(type, change);
+ break;
+ case "setEnumValue":
+ SetEnumValue(type, change);
+ break;
+ default:
+ m_Notes.Add($"unknown change {change.Kind} was skipped");
+ break;
+ }
+ }
+
+ AddProperties(type, job.Changes.Where(c => c.Kind == "addProperty").ToList());
+ AddEnumMembers(type, job.Changes.Where(c => c.Kind == "addEnumMember").ToList());
+
+ if (m_Edits.Count > 0)
+ AddUsings(job.Usings);
+
+ return new PatchResult { Text = m_Edits.Apply(m_Source), Notes = m_Notes };
+ }
+
+ /***************************************************/
+ /**** Finding things ****/
+ /***************************************************/
+
+ [Description("Finds the class, struct, interface or enum with the given name in the parsed file, preferring the one in the given namespace and noting when it falls "
+ + "back to another namespace. Locates the type that the patch changes.")]
+ private BaseTypeDeclarationSyntax FindType(string name, string ns)
+ {
+ List candidates = m_Root.DescendantNodes().OfType().Where(t => t.Identifier.ValueText == name).ToList();
+ BaseTypeDeclarationSyntax match = candidates.FirstOrDefault(t => NamespaceOf(t) == ns);
+ if (match == null && candidates.Count > 0)
+ {
+ match = candidates[0];
+ m_Notes.Add($"the file declares {name} in namespace {NamespaceOf(match)}, the Python class says {ns}; the existing one is kept");
+ }
+
+ return match;
+ }
+
+ /***************************************************/
+
+ [Description("Returns the dotted namespace that encloses a syntax node, from the outermost namespace to the innermost. Used by FindType to match the Python class's namespace.")]
+ private static string NamespaceOf(SyntaxNode node)
+ {
+ return string.Join(".", node.Ancestors().OfType().Reverse().Select(n => n.Name.ToString()));
+ }
+
+ /***************************************************/
+
+ [Description("Lists the property declarations of a class, struct or interface, or an empty list for an enum. Used to look up, anchor and add properties.")]
+ private static List Properties(BaseTypeDeclarationSyntax type)
+ {
+ if (type is TypeDeclarationSyntax declaration)
+ return declaration.Members.OfType().ToList();
+
+ return new List();
+ }
+
+ /***************************************************/
+
+ [Description("Finds the property or enum member with the given name in a type, returning the type itself for an empty name (a change to the type). Notes that the "
+ + "member was not found, so that change is left for the developer to make by hand.")]
+ private MemberDeclarationSyntax FindMember(BaseTypeDeclarationSyntax type, string name, string purpose)
+ {
+ if (string.IsNullOrEmpty(name))
+ return type;
+
+ MemberDeclarationSyntax member = Properties(type).FirstOrDefault(p => p.Identifier.ValueText == name);
+ if (member == null && type is EnumDeclarationSyntax enumeration)
+ member = enumeration.Members.FirstOrDefault(m => m.Identifier.ValueText == name);
+
+ if (member == null)
+ m_Notes.Add($"{name} was not found in {type.Identifier.ValueText}, so its {purpose} was not changed; change it by hand");
+
+ return member;
+ }
+
+ /***************************************************/
+
+ [Description("Lists the attributes with a given name on a member, ignoring the Attribute suffix. Used by the description and attribute changes to find what to replace or remove.")]
+ private static IEnumerable FindAttributes(SyntaxList lists, string name)
+ {
+ string wanted = StripAttributeSuffix(name);
+ return lists.SelectMany(l => l.Attributes).Where(a => AttributeName(a) == wanted).ToList();
+ }
+
+ /***************************************************/
+
+ [Description("Returns the plain name of an attribute without namespace qualifier or Attribute suffix. Lets FindAttributes match names however the existing file writes them.")]
+ private static string AttributeName(AttributeSyntax attribute)
+ {
+ NameSyntax name = attribute.Name;
+ if (name is QualifiedNameSyntax qualified)
+ name = qualified.Right;
+
+ if (name is AliasQualifiedNameSyntax alias)
+ name = alias.Name;
+
+ return StripAttributeSuffix(((SimpleNameSyntax)name).Identifier.ValueText);
+ }
+
+ /***************************************************/
+
+ [Description("Removes a trailing Attribute from an attribute name so Description and DescriptionAttribute compare equal.")]
+ private static string StripAttributeSuffix(string name)
+ {
+ return name.EndsWith("Attribute") && name.Length > "Attribute".Length ? name.Substring(0, name.Length - "Attribute".Length) : name;
+ }
+
+ /***************************************************/
+ /**** Properties ****/
+ /***************************************************/
+
+ [Description("Inserts the properties of the addProperty changes into a type as new lines, after the property named in the change or first when none is named, using "
+ + "the type's indentation and line breaks. Notes properties that already exist, types that cannot hold properties and get-only types, so only the new "
+ + "lines are added to the file.")]
+ private void AddProperties(BaseTypeDeclarationSyntax type, List changes)
+ {
+ if (changes.Count == 0)
+ return;
+
+ if (type is not TypeDeclarationSyntax declaration)
+ {
+ m_Notes.Add($"{type.Identifier.ValueText} cannot have properties; add {string.Join(", ", changes.Select(c => c.Name))} by hand");
+ return;
+ }
+
+ List properties = Properties(type);
+ string indent = MemberIndent(type);
+ bool readOnly = declaration is not InterfaceDeclarationSyntax && properties.Count > 0 && properties.All(IsGetOnly);
+
+ foreach (Change change in changes)
+ {
+ if (properties.Any(p => p.Identifier.ValueText == change.Name))
+ {
+ m_Notes.Add($"{change.Name} is already declared in {declaration.Identifier.ValueText}; it was not added again");
+ continue;
+ }
+
+ List lines = change.Add.Select(a => $"[{a}]").ToList();
+ lines.Add(PropertyLine(declaration, change, readOnly));
+ if (readOnly)
+ m_Notes.Add($"{change.Name} was added as a get-only property because the other properties of {declaration.Identifier.ValueText} are; add it to the constructor by hand");
+
+ PropertyDeclarationSyntax anchor = null;
+ if (!string.IsNullOrEmpty(change.After))
+ {
+ anchor = properties.FirstOrDefault(p => p.Identifier.ValueText == change.After);
+ if (anchor == null)
+ {
+ m_Notes.Add($"{change.After} was not found, so {change.Name} was added after the last property");
+ anchor = properties.LastOrDefault();
+ }
+ }
+
+ string block = Lines(indent, lines);
+ if (anchor != null)
+ m_Edits.Insert(NextLineStart(anchor.Span.End), m_NewLine + block);
+ else if (declaration.Members.Count > 0)
+ m_Edits.Insert(LineStart(StartWithComments(properties.Count > 0 ? properties[0] : declaration.Members[0])), block + m_NewLine);
+ else
+ m_Edits.Insert(NextLineStart(declaration.OpenBraceToken.Span.End), block);
+ }
+ }
+
+ /***************************************************/
+
+ [Description("Builds the C# text of one new property (accessors, virtual modifier and initialiser) for a class, struct or interface. Used by AddProperties.")]
+ private static string PropertyLine(TypeDeclarationSyntax declaration, Change change, bool readOnly)
+ {
+ string name = Identifier(change.Name);
+ if (declaration is InterfaceDeclarationSyntax)
+ return $"{change.Type} {name} {{ get; set; }}";
+
+ string modifier = declaration is StructDeclarationSyntax ? "" : "virtual ";
+ string accessors = readOnly ? "{ get; }" : "{ get; set; }";
+ string initialiser = string.IsNullOrEmpty(change.Initialiser) ? "" : $" = {change.Initialiser};";
+ return $"public {modifier}{change.Type} {name} {accessors}{initialiser}";
+ }
+
+ /***************************************************/
+
+ [Description("Tells whether a property has accessors but no setter or init accessor. AddProperties uses it to add new properties as get-only when all existing ones are.")]
+ private static bool IsGetOnly(PropertyDeclarationSyntax property)
+ {
+ return property.AccessorList != null && !property.AccessorList.Accessors.Any(a => a.IsKind(SyntaxKind.SetAccessorDeclaration) || a.IsKind(SyntaxKind.InitAccessorDeclaration));
+ }
+
+ /***************************************************/
+
+ [Description("Removes the property named in a removeProperty change together with its attributes and comments. Applies a property deletion of the Python class to the existing C# file.")]
+ private void RemoveProperty(BaseTypeDeclarationSyntax type, Change change)
+ {
+ MemberDeclarationSyntax property = FindMember(type, change.Name, "removal");
+ if (property != null)
+ RemoveMemberLines(property);
+ }
+
+ /***************************************************/
+
+ [Description("Replaces the type of the named property with the type of a setPropertyType change, unless it is already the same ignoring spaces. Applies a changed "
+ + "property type of the Python class and leaves the rest of the declaration untouched.")]
+ private void SetPropertyType(BaseTypeDeclarationSyntax type, Change change)
+ {
+ if (FindMember(type, change.Name, "type") is not PropertyDeclarationSyntax property)
+ return;
+
+ if (Normalise(property.Type.ToString()) != Normalise(change.Type))
+ m_Edits.Replace(property.Type.Span.Start, property.Type.Span.End, change.Type);
+ }
+
+ /***************************************************/
+
+ [Description("Replaces, adds or removes the initialiser (default value) of the named property for a setPropertyInitialiser change, doing nothing when the value is "
+ + "already equivalent. Properties with an expression body are noted and left for the developer to change.")]
+ private void SetPropertyInitialiser(BaseTypeDeclarationSyntax type, Change change)
+ {
+ if (FindMember(type, change.Name, "default") is not PropertyDeclarationSyntax property)
+ return;
+
+ if (property.AccessorList == null)
+ {
+ m_Notes.Add($"{change.Name} has an expression body, so its default was not changed; change it by hand");
+ return;
+ }
+
+ string wanted = string.IsNullOrEmpty(change.Initialiser) ? null : change.Initialiser;
+ if (SameValue(property.Initializer?.Value.ToString(), wanted))
+ return;
+
+ int start = property.AccessorList.Span.End;
+ if (property.Initializer != null)
+ m_Edits.Replace(start, property.SemicolonToken.Span.End, wanted == null ? "" : $" = {wanted};");
+ else
+ m_Edits.Insert(start, $" = {wanted};");
+ }
+
+ /***************************************************/
+ /**** Attributes ****/
+ /***************************************************/
+
+ [Description("Replaces, adds or removes the Description attribute of the named member (or of the type) for a setDescription change. Applies a changed docstring of "
+ + "the Python class while leaving other attributes in place.")]
+ private void SetDescription(BaseTypeDeclarationSyntax type, Change change)
+ {
+ MemberDeclarationSyntax member = FindMember(type, change.Name, "description");
+ if (member == null)
+ return;
+
+ AttributeSyntax existing = FindAttributes(member.AttributeLists, "Description").FirstOrDefault();
+ string wanted = string.IsNullOrEmpty(change.Description) ? null : $"Description({change.Description})";
+ if (existing != null && wanted == null)
+ RemoveAttribute(existing);
+ else if (existing != null)
+ m_Edits.Replace(existing.Span.Start, existing.Span.End, wanted);
+ else if (wanted != null)
+ m_Edits.Insert(LineStart(member.SpanStart), IndentAt(member.SpanStart) + $"[{wanted}]" + m_NewLine);
+ }
+
+ /***************************************************/
+
+ [Description("Removes the attributes listed in a setAttributes change from a member and inserts the new ones on lines above it. Applies attribute changes such as "
+ + "quantity or unit attributes from the Python class.")]
+ private void SetAttributes(BaseTypeDeclarationSyntax type, Change change)
+ {
+ MemberDeclarationSyntax member = FindMember(type, change.Name, "attributes");
+ if (member == null)
+ return;
+
+ foreach (string name in change.Remove)
+ {
+ List found = FindAttributes(member.AttributeLists, name).ToList();
+ if (found.Count == 0)
+ m_Notes.Add($"attribute {name} was not found on {change.Name}; nothing was removed");
+
+ foreach (AttributeSyntax attribute in found)
+ RemoveAttribute(attribute);
+ }
+
+ if (change.Add.Count == 0)
+ return;
+
+ int position = LineStart(member.SpanStart);
+ if (member.AttributeLists.Count > 0)
+ {
+ AttributeListSyntax last = member.AttributeLists.Last();
+ SyntaxToken next = last.GetLastToken().GetNextToken();
+ if (LineNumber(next.SpanStart) != LineNumber(last.Span.End))
+ position = NextLineStart(last.Span.End);
+ }
+
+ m_Edits.Insert(position, Lines(IndentAt(member.SpanStart), change.Add.Select(a => $"[{a}]")));
+ }
+
+ /***************************************************/
+
+ [Description("Removes one attribute from a member, together with its whole list or line when nothing else is in it. Used by the description and attribute changes.")]
+ private void RemoveAttribute(AttributeSyntax attribute)
+ {
+ AttributeListSyntax list = (AttributeListSyntax)attribute.Parent;
+ if (list.Attributes.Count > 1)
+ {
+ int index = list.Attributes.IndexOf(attribute);
+ if (index > 0)
+ m_Edits.Remove(list.Attributes[index - 1].Span.End, attribute.Span.End);
+ else
+ m_Edits.Remove(attribute.Span.Start, list.Attributes[1].Span.Start);
+
+ return;
+ }
+
+ TextLine first = m_Text.Lines.GetLineFromPosition(list.SpanStart);
+ TextLine last = m_Text.Lines.GetLineFromPosition(list.Span.End);
+ bool aloneOnItsLines = string.IsNullOrWhiteSpace(m_Text.ToString(TextSpan.FromBounds(first.Start, list.SpanStart)))
+ && string.IsNullOrWhiteSpace(m_Text.ToString(TextSpan.FromBounds(list.Span.End, last.End)));
+
+ if (aloneOnItsLines)
+ {
+ m_Edits.Remove(first.Start, last.EndIncludingLineBreak);
+ return;
+ }
+
+ int end = list.Span.End;
+ while (end < m_Source.Length && m_Source[end] == ' ')
+ end++;
+
+ m_Edits.Remove(list.SpanStart, end);
+ }
+
+ /***************************************************/
+ /**** Bases ****/
+ /***************************************************/
+
+ [Description("Removes and adds base types and interfaces in the base list of a class or struct for a setBases change, keeping the bases that stay. Notes bases that "
+ + "were not written in the file, and writes nothing when the list is unchanged.")]
+ private void SetBases(BaseTypeDeclarationSyntax type, Change change)
+ {
+ if (type is not TypeDeclarationSyntax declaration)
+ {
+ m_Notes.Add($"the bases of {type.Identifier.ValueText} cannot be changed; change them by hand");
+ return;
+ }
+
+ List existing = declaration.BaseList?.Types.ToList() ?? new List();
+ List kept = new List();
+ bool changed = false;
+ foreach (BaseTypeSyntax baseType in existing)
+ {
+ if (change.Remove.Any(r => SameType(baseType.Type.ToString(), r)))
+ changed = true;
+ else
+ kept.Add(baseType.ToString());
+ }
+
+ foreach (string removed in change.Remove.Where(r => !existing.Any(b => SameType(b.Type.ToString(), r))))
+ m_Notes.Add($"{removed} is not written as a base of {declaration.Identifier.ValueText} (it may come through another base); nothing was removed");
+
+ foreach (string added in change.Add.Where(a => !kept.Any(k => SameType(k, a))))
+ {
+ kept.Add(added);
+ changed = true;
+ }
+
+ if (!changed)
+ return;
+
+ int nameEnd = declaration.TypeParameterList?.Span.End ?? declaration.Identifier.Span.End;
+ if (declaration.BaseList == null)
+ m_Edits.Insert(nameEnd, " : " + string.Join(", ", kept));
+ else if (kept.Count == 0)
+ m_Edits.Remove(nameEnd, declaration.BaseList.Span.End);
+ else
+ m_Edits.Replace(existing[0].SpanStart, existing.Last().Span.End, string.Join(", ", kept));
+ }
+
+ /***************************************************/
+ /**** Enum members ****/
+ /***************************************************/
+
+ [Description("Inserts the members of the addEnumMember changes into an enum as new lines, with explicit values and descriptions where needed. Without explicit "
+ + "values new members only go at the end so the existing values do not shift, and each decision that differs from the request is noted.")]
+ private void AddEnumMembers(BaseTypeDeclarationSyntax type, List changes)
+ {
+ if (changes.Count == 0)
+ return;
+
+ if (type is not EnumDeclarationSyntax enumeration)
+ {
+ m_Notes.Add($"{type.Identifier.ValueText} is not an enum; add {string.Join(", ", changes.Select(c => c.Name))} by hand");
+ return;
+ }
+
+ SeparatedSyntaxList members = enumeration.Members;
+ bool explicitValues = members.Any(m => m.EqualsValue != null);
+ bool trailingComma = members.Count > 0 && members.SeparatorCount == members.Count;
+ SortedDictionary> groups = new SortedDictionary>();
+
+ foreach (Change change in changes)
+ {
+ if (members.Any(m => m.Identifier.ValueText == change.Name))
+ {
+ m_Notes.Add($"{change.Name} is already a member of {enumeration.Identifier.ValueText}; it was not added again");
+ continue;
+ }
+
+ //Without explicit values the members after a new one would change value, so new members only go at the end
+ int index = members.Count - 1;
+ if (explicitValues)
+ {
+ index = string.IsNullOrEmpty(change.After) ? -1 : IndexOf(members, change.After);
+ if (index < 0 && !string.IsNullOrEmpty(change.After))
+ {
+ m_Notes.Add($"{change.After} was not found, so {change.Name} was added at the end");
+ index = members.Count - 1;
+ }
+ }
+ else if (change.After != members.LastOrDefault()?.Identifier.ValueText)
+ {
+ m_Notes.Add($"{change.Name} was added at the end of {enumeration.Identifier.ValueText} so the other members keep their values");
+ }
+
+ if (!groups.ContainsKey(index))
+ groups[index] = new List();
+
+ groups[index].Add(change);
+ }
+
+ string indent = MemberIndent(enumeration);
+ int implicitNext = members.Count;
+ foreach (KeyValuePair> group in groups)
+ {
+ bool atEnd = group.Key == members.Count - 1;
+ List