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 lines = new List(); + for (int i = 0; i < group.Value.Count; i++) + { + Change change = group.Value[i]; + if (!string.IsNullOrEmpty(change.Description)) + lines.Add($"[Description({change.Description})]"); + + string value = ""; + if (!string.IsNullOrEmpty(change.Value) && (explicitValues || change.Value != implicitNext.ToString(CultureInfo.InvariantCulture))) + value = " = " + change.Value; + + implicitNext = int.TryParse(change.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number) ? number + 1 : implicitNext + 1; + bool comma = !atEnd || i < group.Value.Count - 1 || trailingComma; + lines.Add(Identifier(change.Name) + value + (comma ? "," : "")); + } + + string block = Lines(indent, lines); + if (members.Count == 0) + { + m_Edits.Insert(NextLineStart(enumeration.OpenBraceToken.Span.End), block); + } + else if (group.Key < 0) + { + m_Edits.Insert(LineStart(StartWithComments(members[0])), block); + } + else if (group.Key < members.SeparatorCount) + { + m_Edits.Insert(NextLineStart(members.GetSeparator(group.Key).Span.End), block); + } + else + { + m_Edits.Insert(members[group.Key].Span.End, ","); + m_Edits.Insert(NextLineStart(members[group.Key].Span.End), block); + } + } + } + + /***************************************************/ + + [Description("Removes the named enum member together with its comments for a removeEnumMember change. Refuses members that share a line with others and notes when " + + "the values of later members shift.")] + private void RemoveEnumMember(BaseTypeDeclarationSyntax type, Change change) + { + if (FindMember(type, change.Name, "removal") is not EnumMemberDeclarationSyntax member) + return; + + SeparatedSyntaxList members = ((EnumDeclarationSyntax)type).Members; + int index = members.IndexOf(member); + bool sharesItsLine = members.Any(m => m != member && LineNumber(m.SpanStart) == LineNumber(member.SpanStart)); + if (sharesItsLine) + { + m_Notes.Add($"{change.Name} shares its line with other members, so it was not removed; remove it by hand"); + return; + } + + int start = LineStart(StartWithComments(member)); + int end = index < members.SeparatorCount ? NextLineStart(members.GetSeparator(index).Span.End) : NextLineStart(member.Span.End); + m_Edits.Remove(start, end); + + if (!members.Any(m => m.EqualsValue != null) && index < members.Count - 1) + m_Notes.Add($"the members after {change.Name} change value now that it is removed; give them explicit values if their numbers matter"); + } + + /***************************************************/ + + [Description("Replaces or adds the explicit value of the named enum member for a setEnumValue change. Applies a changed enum value of the Python class.")] + private void SetEnumValue(BaseTypeDeclarationSyntax type, Change change) + { + if (FindMember(type, change.Name, "value") is not EnumMemberDeclarationSyntax member) + return; + + if (member.EqualsValue != null) + m_Edits.Replace(member.EqualsValue.Value.Span.Start, member.EqualsValue.Value.Span.End, change.Value); + else + m_Edits.Insert(member.Identifier.Span.End, " = " + change.Value); + } + + /***************************************************/ + + [Description("Returns the position of the enum member with the given name, or -1. Used by AddEnumMembers to place new members after a named member.")] + private static int IndexOf(SeparatedSyntaxList members, string name) + { + for (int i = 0; i < members.Count; i++) + { + if (members[i].Identifier.ValueText == name) + return i; + } + + return -1; + } + + /***************************************************/ + /**** Usings ****/ + /***************************************************/ + + [Description("Adds the using directives the job needs that the file does not already have, after the last existing using or above the first member. Apply calls it " + + "only when some edit was made, so untouched files stay as they are.")] + private void AddUsings(List usings) + { + HashSet existing = m_Root.Usings + .Concat(m_Root.DescendantNodes().OfType().SelectMany(n => n.Usings)) + .Select(u => u.Name?.ToString()) + .ToHashSet(); + + List missing = usings.Where(u => !existing.Contains(u)).Distinct().ToList(); + if (missing.Count == 0) + return; + + string text = string.Concat(missing.Select(u => $"using {u};{m_NewLine}")); + if (m_Root.Usings.Count > 0) + m_Edits.Insert(NextLineStart(m_Root.Usings.Last().Span.End), text); + else if (m_Root.Members.Count > 0) + m_Edits.Insert(LineStart(m_Root.Members[0].SpanStart), text + m_NewLine); + else + m_Edits.Insert(0, text); + } + + /***************************************************/ + /**** Text helpers ****/ + /***************************************************/ + + [Description("Removes the lines of a member with the comments directly above it, plus one neighbouring blank line so the spacing of the other members stays the " + + "same. Used when a property is removed.")] + private void RemoveMemberLines(SyntaxNode node) + { + int start = LineStart(StartWithComments(node)); + int startLine = LineNumber(start); + TextLine last = m_Text.Lines.GetLineFromPosition(node.Span.End); + int end = last.EndIncludingLineBreak; + + if (IsBlank(last.LineNumber + 1)) + end = m_Text.Lines[last.LineNumber + 1].EndIncludingLineBreak; + else if (startLine > 0 && IsBlank(startLine - 1) && !m_Removed.Any(s => s.Contains(m_Text.Lines[startLine - 1].Start))) + start = m_Text.Lines[startLine - 1].Start; + + m_Removed.Add(TextSpan.FromBounds(start, end)); + m_Edits.Remove(start, end); + } + + /***************************************************/ + + [Description("Returns the position where a member's directly preceding comments start, so edits that remove or insert before a member keep its comments with it.")] + private static int StartWithComments(SyntaxNode node) + { + int start = node.SpanStart; + int lineBreaks = 0; + foreach (SyntaxTrivia trivia in node.GetLeadingTrivia().Reverse()) + { + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + { + lineBreaks++; + if (lineBreaks > 1) + break; + } + else if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia) + || trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia)) + { + start = trivia.SpanStart; + //A documentation comment includes its own line break + lineBreaks = trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) ? 1 : 0; + } + else if (!trivia.IsKind(SyntaxKind.WhitespaceTrivia)) + { + break; + } + } + + return start; + } + + /***************************************************/ + + [Description("Returns the indentation used by the existing members of a type, or one level deeper than the type when it is empty. New properties and enum members are written with it.")] + private string MemberIndent(BaseTypeDeclarationSyntax type) + { + SyntaxNode first = null; + if (type is TypeDeclarationSyntax declaration && declaration.Members.Count > 0) + first = declaration.Members[0]; + else if (type is EnumDeclarationSyntax enumeration && enumeration.Members.Count > 0) + first = enumeration.Members[0]; + + return first != null ? IndentAt(first.SpanStart) : IndentAt(type.SpanStart) + " "; + } + + /***************************************************/ + + [Description("Joins lines of text into a block that is indented and ends each line with the file's own line break. Builds the text of inserted members.")] + private string Lines(string indent, IEnumerable lines) + { + return string.Concat(lines.Select(l => indent + l + m_NewLine)); + } + + /***************************************************/ + + [Description("Returns the line number of a position in the original text. Used to tell whether two nodes share a line.")] + private int LineNumber(int position) + { + return m_Text.Lines.GetLineFromPosition(position).LineNumber; + } + + /***************************************************/ + + [Description("Returns the position where the line of a given position starts in the original text. Used to insert whole lines.")] + private int LineStart(int position) + { + return m_Text.Lines.GetLineFromPosition(position).Start; + } + + /***************************************************/ + + [Description("Returns the position just after the line break of the line holding a given position. Used to insert or remove after a whole line.")] + private int NextLineStart(int position) + { + return m_Text.Lines.GetLineFromPosition(position).EndIncludingLineBreak; + } + + /***************************************************/ + + [Description("Returns the leading whitespace of the line at a position. Used to indent inserted attribute lines like the member they belong to.")] + private string IndentAt(int position) + { + string line = m_Text.Lines.GetLineFromPosition(position).ToString(); + return line.Substring(0, line.Length - line.TrimStart().Length); + } + + /***************************************************/ + + [Description("Tells whether a line of the original text exists and is empty or whitespace. RemoveMemberLines uses it to decide which blank line goes with a removed member.")] + private bool IsBlank(int lineNumber) + { + return lineNumber < m_Text.Lines.Count && string.IsNullOrWhiteSpace(m_Text.Lines[lineNumber].ToString()); + } + + /***************************************************/ + + [Description("Returns a name as a valid C# identifier, adding @ when it is a keyword. Applied to names of new properties and enum members.")] + private static string Identifier(string name) + { + return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name; + } + + /***************************************************/ + + [Description("Removes all whitespace from a text so that C# types and values can be compared regardless of formatting.")] + private static string Normalise(string text) + { + return new string((text ?? "").Where(c => !char.IsWhiteSpace(c)).ToArray()); + } + + /***************************************************/ + + [Description("Tells whether two initialisers mean the same value, for example 0 and 0.0, or null and no initialiser. SetPropertyInitialiser uses it to avoid " + + "rewriting a default that has not really changed.")] + private static bool SameValue(string current, string wanted) + { + current = current == "null" ? null : current; + wanted = wanted == "null" ? null : wanted; + if (current == null || wanted == null) + return current == wanted; + + if (Normalise(current) == Normalise(wanted)) + return true; + + return double.TryParse(current.TrimEnd('f', 'F', 'd', 'D', 'm', 'M'), NumberStyles.Float, CultureInfo.InvariantCulture, out double a) + && double.TryParse(wanted.TrimEnd('f', 'F', 'd', 'D', 'm', 'M'), NumberStyles.Float, CultureInfo.InvariantCulture, out double b) + && a == b; + } + + /***************************************************/ + + [Description("Tells whether two base types are the same, comparing the simple name when one is written with its namespace. SetBases uses it to match bases from " + + "Python with those in the file.")] + private static bool SameType(string a, string b) + { + return Normalise(a) == Normalise(b) || SimpleName(a) == SimpleName(b); + } + + /***************************************************/ + + [Description("Returns a type name without its namespace but keeping generic arguments. Used by SameType.")] + private static string SimpleName(string text) + { + string name = Normalise(text); + int generic = name.IndexOf('<'); + string head = generic < 0 ? name : name.Substring(0, generic); + string arguments = generic < 0 ? "" : name.Substring(generic); + return head.Substring(head.LastIndexOf('.') + 1) + arguments; + } + + /***************************************************/ + } +} diff --git a/.ci/generation/PythonSchemaGeneration.Patcher/PatchJob.cs b/.ci/generation/PythonSchemaGeneration.Patcher/PatchJob.cs new file mode 100644 index 0000000..9b94a1f --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration.Patcher/PatchJob.cs @@ -0,0 +1,76 @@ +namespace PythonSchemaGeneration.Patcher +{ + //The job file written by py2cs: one entry per existing C# file to change + public class PatchJob + { + public List Files { get; set; } = new List(); + } + + /***************************************************/ + + public class FileJob + { + public string Path { get; set; } + + public string Output { get; set; } + + //Simple name of the type to change, without namespace or generic arity + public string Type { get; set; } + + public string Namespace { get; set; } + + public List Usings { get; set; } = new List(); + + public List Changes { get; set; } = new List(); + } + + /***************************************************/ + + //One member-level change. Kind is one of addProperty, removeProperty, setPropertyType, setPropertyInitialiser, setDescription, + //setAttributes, setBases, addEnumMember, removeEnumMember, setEnumValue. Texts are C# source, ready to be written. + public class Change + { + public string Kind { get; set; } + + //Member name; empty for changes to the type itself + public string Name { get; set; } + + public string Type { get; set; } + + public string Initialiser { get; set; } + + //C# string literal, quotes included; empty to remove the description + public string Description { get; set; } + + public string Value { get; set; } + + //The existing member after which a new one goes; empty to put it first + public string After { get; set; } + + //Attributes (without brackets) or base types to add + public List Add { get; set; } = new List(); + + //Attribute names or base types to remove + public List Remove { get; set; } = new List(); + } + + /***************************************************/ + + public class FileResult + { + public string Output { get; set; } + + public List Notes { get; set; } = new List(); + + public string Error { get; set; } + } + + /***************************************************/ + + public class PatchResult + { + public string Text { get; set; } + + public List Notes { get; set; } = new List(); + } +} diff --git a/.ci/generation/PythonSchemaGeneration.Patcher/Program.cs b/.ci/generation/PythonSchemaGeneration.Patcher/Program.cs new file mode 100644 index 0000000..323ab12 --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration.Patcher/Program.cs @@ -0,0 +1,40 @@ +using PythonSchemaGeneration.Patcher; +using System.Text; +using System.Text.Json; + +//Usage: PythonSchemaGeneration.Patcher +//Changes existing C# files member by member as the job lists, writes each result to its output path, and prints the notes as json. +if (args.Length != 1) +{ + Console.Error.WriteLine("Usage: PythonSchemaGeneration.Patcher "); + return 2; +} + +Console.OutputEncoding = new UTF8Encoding(false); +JsonSerializerOptions options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; +PatchJob job = JsonSerializer.Deserialize(File.ReadAllText(args[0]), options); +List results = new List(); + +foreach (FileJob file in job.Files) +{ + FileResult result = new FileResult { Output = file.Output }; + try + { + //Keep the byte order mark of the existing file, if it has one + byte[] bytes = File.ReadAllBytes(file.Path); + bool bom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; + string source = new UTF8Encoding(false).GetString(bytes, bom ? 3 : 0, bytes.Length - (bom ? 3 : 0)); + PatchResult patched = FilePatcher.Patch(source, file); + File.WriteAllText(file.Output, patched.Text, new UTF8Encoding(bom)); + result.Notes = patched.Notes; + } + catch (Exception e) + { + result.Error = e.Message; + } + + results.Add(result); +} + +Console.WriteLine(JsonSerializer.Serialize(new { Files = results }, options)); +return 0; diff --git a/.ci/generation/PythonSchemaGeneration.Patcher/PythonSchemaGeneration.Patcher.csproj b/.ci/generation/PythonSchemaGeneration.Patcher/PythonSchemaGeneration.Patcher.csproj new file mode 100644 index 0000000..d65073a --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration.Patcher/PythonSchemaGeneration.Patcher.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + disable + + + + + + + diff --git a/.ci/generation/PythonSchemaGeneration.Patcher/TextEdits.cs b/.ci/generation/PythonSchemaGeneration.Patcher/TextEdits.cs new file mode 100644 index 0000000..4252f06 --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration.Patcher/TextEdits.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using System.Text; + +namespace PythonSchemaGeneration.Patcher +{ + //Edits to a source text, applied together so every position refers to the original text + public class TextEdits + { + /***************************************************/ + + private class Edit + { + public int Start { get; set; } + + public int End { get; set; } + + public string Text { get; set; } + + public int Order { get; set; } + } + + private readonly List m_Edits = new List(); + + /***************************************************/ + + public int Count { get { return m_Edits.Count; } } + + /***************************************************/ + + [Description("Records the replacement of the text between start and end, to be applied later together with the other edits. The patcher collects every change this " + + "way so all positions refer to the original text; inserts at one position keep the order they were added.")] + public void Replace(int start, int end, string text) + { + m_Edits.Add(new Edit { Start = start, End = end, Text = text, Order = m_Edits.Count }); + } + + /***************************************************/ + + [Description("Records an insertion of text at a position of the original source. Shorthand for an empty replacement, used by FilePatcher to add members, attributes and usings.")] + public void Insert(int position, string text) + { + Replace(position, position, text); + } + + /***************************************************/ + + [Description("Records the removal of the text between start and end of the original source. Shorthand for replacing with nothing, used by FilePatcher to delete members and attributes.")] + public void Remove(int start, int end) + { + Replace(start, end, ""); + } + + /***************************************************/ + + [Description("Returns the source with all recorded edits applied in position order, leaving the text between them unchanged. Overlapping edits throw an error rather " + + "than guessing, so py2cs reports the file for hand editing.")] + public string Apply(string source) + { + StringBuilder result = new StringBuilder(); + int cursor = 0; + foreach (Edit edit in m_Edits.OrderBy(e => e.Start).ThenBy(e => e.End == e.Start ? 0 : 1).ThenBy(e => e.Order)) + { + if (edit.Start < cursor) + throw new InvalidOperationException($"two changes overlap at position {edit.Start}; apply them by hand"); + + result.Append(source, cursor, edit.Start - cursor); + result.Append(edit.Text); + cursor = edit.End; + } + + result.Append(source, cursor, source.Length - cursor); + return result.ToString(); + } + + /***************************************************/ + } +} diff --git a/.ci/generation/PythonSchemaGeneration.sln b/.ci/generation/PythonSchemaGeneration.sln new file mode 100644 index 0000000..c53d3f9 --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PythonSchemaGeneration.Core", "PythonSchemaGeneration.Core\PythonSchemaGeneration.Core.csproj", "{3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PythonSchemaGeneration", "PythonSchemaGeneration\PythonSchemaGeneration.csproj", "{CEEC4887-9075-408E-B74B-BE1F3FCBE13F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PythonSchemaGeneration.Tests", "..\unit-tests\csharp\PythonSchemaGeneration.Tests\PythonSchemaGeneration.Tests.csproj", "{A9209835-DDD9-470D-A1F4-C9EB8015ABED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PythonSchemaGeneration.Patcher", "PythonSchemaGeneration.Patcher\PythonSchemaGeneration.Patcher.csproj", "{6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|x64.Build.0 = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Debug|x86.Build.0 = Debug|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|Any CPU.Build.0 = Release|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|x64.ActiveCfg = Release|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|x64.Build.0 = Release|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|x86.ActiveCfg = Release|Any CPU + {3BA3E13C-CEC3-465E-B43D-6E3A7E901C19}.Release|x86.Build.0 = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|x64.Build.0 = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Debug|x86.Build.0 = Debug|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|Any CPU.Build.0 = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|x64.ActiveCfg = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|x64.Build.0 = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|x86.ActiveCfg = Release|Any CPU + {CEEC4887-9075-408E-B74B-BE1F3FCBE13F}.Release|x86.Build.0 = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|x64.ActiveCfg = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|x64.Build.0 = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|x86.ActiveCfg = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Debug|x86.Build.0 = Debug|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|Any CPU.Build.0 = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|x64.ActiveCfg = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|x64.Build.0 = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|x86.ActiveCfg = Release|Any CPU + {A9209835-DDD9-470D-A1F4-C9EB8015ABED}.Release|x86.Build.0 = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|x64.Build.0 = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|x86.ActiveCfg = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Debug|x86.Build.0 = Debug|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|Any CPU.Build.0 = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|x64.ActiveCfg = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|x64.Build.0 = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|x86.ActiveCfg = Release|Any CPU + {6BB0D6C0-5A6F-4421-AAF2-FCA83D101E3F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/.ci/generation/PythonSchemaGeneration/CommandLine.cs b/.ci/generation/PythonSchemaGeneration/CommandLine.cs new file mode 100644 index 0000000..0f73b9e --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration/CommandLine.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; + +namespace PythonSchemaGeneration +{ + //The command line of the generator. Options that are not known are an error, so a misspelt one is not silently ignored. + public class CommandLine + { + /***************************************************/ + + private static readonly string[] m_Known = { "output", "assemblies", "org", "repos", "types", "manifest", "report", "baseline-warnings", "write-baseline", "release" }; + + public const string Usage = "Usage: PythonSchemaGeneration --output [--assemblies ] [--org BHoM,Other] [--repos BHoM/BHoM,BHoM/Other_Toolkit] " + + "[--types A,B] [--manifest ] [--report ] [--baseline-warnings ] [--write-baseline ] [--release ]"; + + public GenerationOptions Options { get; private set; } + + //Known warning kinds; a run with a kind that is not in this file fails + public string BaselinePath { get; private set; } + + //Writes the kinds of this run's warnings to this file + public string WriteBaselinePath { get; private set; } + + public string Error { get; private set; } + + /***************************************************/ + + [Description("Parses the generator's command-line arguments into the generation options and the warning-baseline paths, or sets an error for an unknown option, a " + + "missing value or a missing --output. The first stage of the generator's Program, which exits with code 2 on an error.")] + public static CommandLine Parse(string[] args) + { + CommandLine result = new CommandLine(); + Dictionary arguments = new Dictionary(); + for (int i = 0; i < args.Length; i++) + { + if (!args[i].StartsWith("--")) + return result.Fail($"Unexpected argument '{args[i]}'."); + + string name = args[i].Substring(2); + if (!m_Known.Contains(name)) + return result.Fail($"Unknown option --{name}."); + + if (i + 1 >= args.Length || args[i + 1].StartsWith("--")) + return result.Fail($"Option --{name} needs a value."); + + arguments[name] = args[++i]; + } + + if (!arguments.ContainsKey("output")) + return result.Fail("Missing --output ."); + + result.Options = new GenerationOptions + { + OutputFolder = arguments["output"], + ReportPath = arguments.GetValueOrDefault("report"), + ManifestPath = arguments.GetValueOrDefault("manifest"), + AssemblyFolder = arguments.GetValueOrDefault("assemblies"), + }; + + if (arguments.TryGetValue("org", out string orgs)) + result.Options.Organisations = Split(orgs); + + if (arguments.TryGetValue("repos", out string repos)) + result.Options.Repos = Split(repos); + + if (arguments.TryGetValue("types", out string types)) + result.Options.TypeFilter = Split(types); + + result.Options.ReleaseVersion = arguments.GetValueOrDefault("release"); + result.BaselinePath = arguments.GetValueOrDefault("baseline-warnings"); + result.WriteBaselinePath = arguments.GetValueOrDefault("write-baseline"); + return result; + } + + /***************************************************/ + + [Description("Stores an error message and returns the command line so Parse can report a failure in one statement.")] + private CommandLine Fail(string message) + { + Error = message; + return this; + } + + /***************************************************/ + + [Description("Splits a comma-separated option value into trimmed, non-empty items. Used for --org, --repos and --types.")] + private static List Split(string text) + { + return text.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToList(); + } + + /***************************************************/ + } +} diff --git a/.ci/generation/PythonSchemaGeneration/Program.cs b/.ci/generation/PythonSchemaGeneration/Program.cs new file mode 100644 index 0000000..bfe44c7 --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration/Program.cs @@ -0,0 +1,55 @@ +using PythonSchemaGeneration; +using PythonSchemaGeneration.Reporting; + +//Exit codes: 0 done, 1 a type could not be converted or a warning kind is not in the baseline, 2 wrong command line +CommandLine command = CommandLine.Parse(args); +if (command.Error != null) +{ + Console.Error.WriteLine(command.Error); + Console.Error.WriteLine(CommandLine.Usage); + return 2; +} + +GenerationResult result; +try +{ + result = Generator.Run(command.Options); +} +catch (InvalidOperationException e) +{ + Console.Error.WriteLine("error: " + e.Message); + return 1; +} + +Console.WriteLine($"Generated {result.Types.Count} of {result.AvailableTypes} types, wrote {result.FilesWritten} files, {result.Reporter.Warnings.Count} warnings."); + +foreach (string warning in result.Reporter.Warnings.Take(25)) + Console.WriteLine(" warning: " + warning); + +int exitCode = 0; +foreach (string type in result.FailedTypes) +{ + Console.Error.WriteLine($"error: {type} could not be converted"); + exitCode = 1; +} + +if (command.WriteBaselinePath != null) +{ + WarningBaseline.Write(command.WriteBaselinePath, result.Reporter.Warnings); + Console.WriteLine($"Wrote {WarningBaseline.Kinds(result.Reporter.Warnings).Count} warning kinds to {command.WriteBaselinePath}"); +} + +if (command.BaselinePath != null) +{ + (List newKinds, List goneKinds) = WarningBaseline.Compare(result.Reporter.Warnings, WarningBaseline.Read(command.BaselinePath)); + foreach (string kind in goneKinds) + Console.WriteLine($"note: the warning kind '{kind}' no longer occurs; remove it from the baseline"); + + foreach (string kind in newKinds) + { + Console.Error.WriteLine($"error: new warning kind not in the baseline: {kind}"); + exitCode = 1; + } +} + +return exitCode; diff --git a/.ci/generation/PythonSchemaGeneration/PythonSchemaGeneration.csproj b/.ci/generation/PythonSchemaGeneration/PythonSchemaGeneration.csproj new file mode 100644 index 0000000..1a65efa --- /dev/null +++ b/.ci/generation/PythonSchemaGeneration/PythonSchemaGeneration.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + disable + + + + + + + diff --git a/.ci/generation/py2cs/__init__.py b/.ci/generation/py2cs/__init__.py new file mode 100644 index 0000000..4252032 --- /dev/null +++ b/.ci/generation/py2cs/__init__.py @@ -0,0 +1 @@ +"""Python to C# converter: reads the generated Pydantic classes and drafts aligned C# for the linked PR.""" diff --git a/.ci/generation/py2cs/__main__.py b/.ci/generation/py2cs/__main__.py new file mode 100644 index 0000000..1f60a48 --- /dev/null +++ b/.ci/generation/py2cs/__main__.py @@ -0,0 +1,298 @@ +"""Command line: python -m py2cs check | manifest | diff | align | repos | plan | pr | close | status | verify | drafts | resolve | notes""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +from py2cs.align import AlignError, align, changed_types, generate +from py2cs.check import check_package +from py2cs.ci import affected_repos, load_allowed, make_client, verify_package +from py2cs.csharp import emit_new +from py2cs.diff import diff_manifests +from py2cs.model import dump_manifest +from py2cs.notes import render_notes +from py2cs.patch import FilePatch, PatchResult, run_patcher +from py2cs.plan import Patcher, make_plan +from py2cs.pr import PythonPr, close_linked, raise_pr +from py2cs.status import update_status +from py2cs.reader import ReadErrors, read_package +from py2cs.report import render_report +from py2cs.resolve import ResolveError, load_listing, resolve_repo +from py2cs.github import GitHubError + +def main(argv: list[str] | None = None) -> int: + """Builds the argument parser with every command and its options, parses the arguments, runs them and turns failures into exit codes (1 for Python read errors, 2 for GitHub, alignment and value errors). + + It is the entry point of `python -m py2cs`, used by the workflows and the tests. + """ + parser = argparse.ArgumentParser(prog="py2cs", description="Python to C# converter for the BHoM schema") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("check", help="read a bhom_schema folder strictly and list every problem; drafts nothing") + p.add_argument("--root", required=True, type=Path) + p.add_argument("--base-root", type=Path, help="the folder before the change; new classes and fields are then checked for descriptions") + + p = sub.add_parser("manifest", help="read a bhom_schema folder into a manifest file") + p.add_argument("--root", required=True, type=Path) + p.add_argument("--out", required=True, type=Path) + + p = sub.add_parser("diff", help="what changed between two bhom_schema folders") + p.add_argument("--base-root", required=True, type=Path) + p.add_argument("--head-root", required=True, type=Path) + p.add_argument("--out", required=True, type=Path) + + p = sub.add_parser("align", help="compare the changed classes of a Python PR with the Python generated from the merged C#") + p.add_argument("--base-root", required=True, type=Path) + p.add_argument("--head-root", required=True, type=Path) + source = p.add_mutually_exclusive_group(required=True) + source.add_argument("--assemblies", type=Path, help="folder with the built oM dlls of the merged C#; the generator is run for the changed classes") + source.add_argument("--generated-root", type=Path, help="a bhom_schema folder that was already generated from the merged C#") + p.add_argument("--generator", type=Path, help="PythonSchemaGeneration.dll, when not in its build folder") + p.add_argument("--work", type=Path, help="folder for the generated Python (default: a temporary one)") + + p = sub.add_parser("repos", help="the repositories a Python change touches, as a json list (for the workflow matrix)") + p.add_argument("--base-root", required=True, type=Path) + p.add_argument("--head-root", required=True, type=Path) + p.add_argument("--allowed-repos", type=Path, help="text file with the repositories C# pull requests may be raised in") + + p = sub.add_parser("plan", help="draft the C# files for a diff; never writes into the C# checkouts") + p.add_argument("--base-root", required=True, type=Path) + p.add_argument("--head-root", required=True, type=Path) + p.add_argument("--clones-parent", type=Path, help="folder holding one checkout per repository, named after the repository") + p.add_argument("--listing", type=Path, help="repository listing json; validates the repo of every class") + p.add_argument("--python-pr", default="", help="reference of the Python pull request, for the report") + p.add_argument("--patcher", type=Path, help="PythonSchemaGeneration.Patcher.dll, when not in its build folder") + p.add_argument("--allowed-repos", type=Path, help="text file with the repositories C# pull requests may be raised in; others are errors") + p.add_argument("--only-repo", help="plan only the classes of this repository (the linked-PR job has one checkout per repository)") + p.add_argument("--out", required=True, type=Path) + + p = sub.add_parser("pr", help="push a plan to the C# repository and open or update the draft pull request") + p.add_argument("--plan-dir", required=True, type=Path) + p.add_argument("--repo", required=True, help="the C# repository, Owner/Name") + p.add_argument("--python-repo", required=True, help="this repository, Owner/Name") + p.add_argument("--python-pr", required=True, type=int) + p.add_argument("--python-url", default="") + p.add_argument("--token-env", default="PY2CS_CSHARP_TOKEN", help="environment variable with the token for the C# repository only") + p.add_argument("--python-token-env", default="PY2CS_PYTHON_TOKEN", help="environment variable with the token that comments on the Python pull request") + p.add_argument("--dry-run", action="store_true", help="write nothing; list what would be done (offline when there is no token)") + + p = sub.add_parser("close", help="when the Python PR is closed: close the linked C# draft (unmerged) or comment on it (merged)") + p.add_argument("--repo", required=True) + p.add_argument("--python-repo", required=True) + p.add_argument("--python-pr", required=True, type=int) + p.add_argument("--merged", action="store_true") + p.add_argument("--token-env", default="PY2CS_CSHARP_TOKEN") + p.add_argument("--dry-run", action="store_true") + + p = sub.add_parser("status", help="set the commit status of a Python PR to whether its linked C# pull request is merged") + p.add_argument("--python-repo", required=True) + p.add_argument("--python-pr", required=True, type=int) + p.add_argument("--sha", required=True, help="head commit of the Python pull request") + p.add_argument("--no-csharp-change", action="store_true", help="the Python pull request needs no C# change: the status is success") + p.add_argument("--repos-file", required=True, type=Path, help="text file with the repositories C# pull requests are raised in") + p.add_argument("--token-env", default="PY2CS_PYTHON_TOKEN", help="environment variable with a token that can read the C# repositories and write statuses here") + p.add_argument("--dry-run", action="store_true") + + p = sub.add_parser("verify", help="every module of a generated package imports, and it reads back as the C# manifest says") + p.add_argument("--package", required=True, type=Path) + p.add_argument("--manifest", type=Path) + + p = sub.add_parser("drafts", help="draft C# for every class of a bhom_schema folder (used by the round trip tests)") + p.add_argument("--root", required=True, type=Path) + p.add_argument("--out", required=True, type=Path) + + p = sub.add_parser("resolve", help="which repository owns each class") + p.add_argument("--root", required=True, type=Path) + p.add_argument("--listing", type=Path) + + p = sub.add_parser("notes", help="release notes: what changed between the previous release and a new bhom_schema folder, breaking changes first") + p.add_argument("--head-root", required=True, type=Path) + p.add_argument("--base-root", type=Path, help="the previous release; leave out for the first release") + p.add_argument("--version", required=True) + p.add_argument("--previous", help="the previous release's tag, named in the notes") + p.add_argument("--out", required=True, type=Path) + + args = parser.parse_args(argv) + try: + return run(args) + except ReadErrors as e: + for error in e.errors: + print(f"error: {error}", file=sys.stderr) + print(f"{len(e.errors)} problems in the Python files", file=sys.stderr) + return 1 + # After ReadErrors, which is a ValueError too + except (GitHubError, AlignError, ValueError) as e: + print(f"error: {e}", file=sys.stderr) + return 2 + +def run(args: argparse.Namespace) -> int: + """Dispatcher of the commands: runs the command named in the arguments (check, manifest, diff, align, repos, plan, pr, close, status, verify, drafts or resolve), prints its result and returns the exit code. + + Each branch wires the py2cs modules together for one workflow step; unknown commands return 2. + """ + if args.command == "check": + result = check_package(args.root, args.base_root) + for error in result.errors: + print(f"error: {error}") + for warning in result.warnings: + print(f"warning: {warning}") + print(f"{len(result.errors)} errors, {len(result.warnings)} warnings") + return 1 if result.errors else 0 + + if args.command == "manifest": + dump_manifest(read_package(args.root), args.out) + return 0 + + if args.command == "diff": + diff = diff_manifests(read_package(args.base_root), read_package(args.head_root)) + args.out.write_text(json.dumps(diff.to_json(), indent=2) + "\n", encoding="utf-8") + print(f"{len(diff.changes)} changed types") + return 0 + + if args.command == "notes": + base = read_package(args.base_root) if args.base_root is not None else {} + diff = diff_manifests(base, read_package(args.head_root)) + args.out.write_text(render_notes(diff, args.version, args.previous), encoding="utf-8") + print(f"release notes for {len(diff.changes)} changed types") + return 0 + + if args.command == "align": + base, head = read_package(args.base_root), read_package(args.head_root) + changed, removed = changed_types(base, head) + if not changed and not removed: + print("no changed classes, nothing to compare") + return 0 + + if args.generated_root is not None: + generated = read_package(args.generated_root) + else: + with tempfile.TemporaryDirectory() as scratch: + output = (args.work or Path(scratch)) / "bhom_schema" + if output.exists(): + shutil.rmtree(output) + generate(changed + removed, args.assemblies, output, args.generator) + generated = read_package(output) + + result = align(base, head, generated) + for problem in result.problems: + print(f"difference: {problem}") + print(f"{len(result.checked)} classes compared, {len(result.problems)} differences" + ("" if result.ok else " (first value: the C#, second: this PR)")) + return 0 if result.ok else 1 + + if args.command == "plan": + base, head = read_package(args.base_root), read_package(args.head_root) + diff = diff_manifests(base, head) + listing = load_listing(args.listing) if args.listing else load_allowed(args.allowed_repos) if args.allowed_repos else None + + def clone_for(repo: str) -> Path | None: + """Finds the local checkout of a C# repository under the clones folder, by the repository name and ignoring case, or returns None. Passed to `make_plan` by the `plan` command. + """ + if args.clones_parent is None: + return None + path = args.clones_parent / repo.split("/")[-1] + if path.is_dir(): + return path + matches = [c for c in args.clones_parent.iterdir() if c.name.lower() == repo.split("/")[-1].lower()] if args.clones_parent.is_dir() else [] + return matches[0] if matches else None + + def patch_with(patches: list[FilePatch]) -> list[PatchResult]: + """Runs the C# patcher with the dll path given on the command line. Passed to `make_plan` by the `plan` command when --patcher is set.""" + return run_patcher(patches, args.patcher) + + patcher: Patcher | None = patch_with if args.patcher else None + plan = make_plan(diff, head, base, clone_for, listing, patcher, args.only_repo) + args.out.mkdir(parents=True, exist_ok=True) + for f in plan.files: + target = args.out / f.repo.replace("/", "__") / f.path + target.parent.mkdir(parents=True, exist_ok=True) + # newline="" keeps the line endings of the existing file + target.write_text(f.text, encoding="utf-8-sig" if f.bom else "utf-8", newline="") + (args.out / "plan.json").write_text(json.dumps(plan.summary(), indent=2) + "\n", encoding="utf-8") + (args.out / "report.md").write_text(render_report(plan, args.python_pr), encoding="utf-8") + (args.out / "reports").mkdir(exist_ok=True) + for repo in plan.repos(): + (args.out / "reports" / (repo.replace("/", "__") + ".md")).write_text(render_report(plan, args.python_pr, repo), encoding="utf-8") + print(f"{len(plan.files)} files planned in {len(plan.repos())} repositories, {len(plan.errors)} problems") + return 1 if plan.errors else 0 + + if args.command == "repos": + listing = load_allowed(args.allowed_repos) if args.allowed_repos else None + repos, problems = affected_repos(read_package(args.base_root), read_package(args.head_root), listing) + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + print(json.dumps(repos)) + return 1 if problems else 0 + + if args.command == "pr": + client, offline = make_client(args.token_env, [args.repo] if args.dry_run else None) + python_client = None if offline else make_client(args.python_token_env)[0] if os.environ.get(args.python_token_env) else None + if offline: + print("no token: offline dry run, nothing is read from or written to GitHub") + python_pr = PythonPr(args.python_repo, args.python_pr, args.python_url) + outcome = raise_pr(client, args.plan_dir, args.repo, python_pr, args.dry_run, python_client) + for action in outcome.actions: + print(("would " if args.dry_run else "") + str(action)) + if outcome.pull_request is not None: + print(f"pull request: {outcome.pull_request.url}") + return 0 + + if args.command == "close": + client, offline = make_client(args.token_env, [args.repo] if args.dry_run else None) + outcome = close_linked(client, args.repo, PythonPr(args.python_repo, args.python_pr), args.merged, args.dry_run) + for action in outcome.actions: + print(("would " if args.dry_run else "") + str(action)) + return 0 + + if args.command == "status": + repos = list(load_allowed(args.repos_file)) + client, offline = make_client(args.token_env, repos if args.dry_run else None) + if offline: + print("no token: offline dry run, nothing is read from or written to GitHub") + status = update_status(client, client, repos, args.python_repo, args.python_pr, args.sha, args.dry_run, args.no_csharp_change) + if status is None: + print(f"{args.python_repo}#{args.python_pr} has no linked C# pull request: no status") + else: + print(("would set " if args.dry_run else "set ") + f"the status {status.state}: {status.description}") + return 0 + + if args.command == "verify": + count, problems = verify_package(args.package, args.manifest) + for problem in problems: + print(f"error: {problem}") + print(f"{count} modules imported, {len(problems)} problems") + return 1 if problems else 0 + + if args.command == "drafts": + manifest = read_package(args.root) + if args.out.exists(): + shutil.rmtree(args.out) + for info in manifest.values(): + target = args.out / Path(info.relative_path).with_suffix(".cs") + target.parent.mkdir(parents=True, exist_ok=True) + # The generated types keep their C# shape: fields without a default come from immutable types + target.write_text(emit_new(info, manifest, read_only_required=True), encoding="utf-8", newline="\n") + print(f"{len(manifest)} drafts written") + return 0 + + if args.command == "resolve": + listing = load_listing(args.listing) if args.listing else None + status = 0 + for clr_name, info in sorted(read_package(args.root).items()): + try: + resolution = resolve_repo(info, listing) + print(f"{clr_name}\t{resolution.repo}\t{resolution.source}") + except ResolveError as e: + print(f"{clr_name}\tERROR\t{e}") + status = 1 + return status + + return 2 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ci/generation/py2cs/align.py b/.ci/generation/py2cs/align.py new file mode 100644 index 0000000..a636983 --- /dev/null +++ b/.ci/generation/py2cs/align.py @@ -0,0 +1,74 @@ +"""The alignment check: does the Python of a PR equal the Python the generator writes from the merged C#? + +The C# is the source of truth. A Python PR is only a proposal until the C# has the same change, so the check asks the generator +what the C# says about the changed classes and compares that with the PR. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +from py2cs.compare import compare_types +from py2cs.diff import diff_manifests +from py2cs.model import Manifest + +GENERATOR = Path(__file__).resolve().parents[1] / "PythonSchemaGeneration" / "bin" / "Release" / "net8.0" / "PythonSchemaGeneration.dll" +BUILD_HINT = "build it with: dotnet build .ci/generation/PythonSchemaGeneration.sln -c Release" + +class AlignError(Exception): + """The check could not be run (as opposed to running and finding differences).""" + +@dataclass +class AlignResult: + # The types that were compared + checked: list[str] = field(default_factory=lambda: []) + # One line per difference. In a "differs" line the first value comes from the C#, the second from the Python PR. + problems: list[str] = field(default_factory=lambda: []) + + @property + def ok(self) -> bool: + """Tells whether the alignment check found no difference. The `align` command returns exit code 0 when it is true.""" + return not self.problems + +def changed_types(base: Manifest, head: Manifest) -> tuple[list[str], list[str]]: + """(added or modified, removed) class names between the two versions of the Python.""" + diff = diff_manifests(base, head) + return [c.clr_name for c in diff.changes if c.kind != "removed"], [c.clr_name for c in diff.changes if c.kind == "removed"] + +def align(base: Manifest, head: Manifest, generated: Manifest) -> AlignResult: + """Compares the changed classes of a PR with the classes generated from the merged C#.""" + result = AlignResult() + changed, removed = changed_types(base, head) + for name in changed: + result.checked.append(name) + csharp = generated.get(name) + if csharp is None: + result.problems.append(f"{name}: not found in the C#; merge and build the linked C# pull request first") + continue + result.problems += compare_types(csharp, head[name]) + + for name in removed: + result.checked.append(name) + if name in generated: + result.problems.append(f"{name}: removed in the Python, but the C# still declares it") + return result + +def default_generator() -> Path: + """Returns the path of the built C# generator dll that `generate` runs when no other path is given.""" + return GENERATOR + +def generate(types: list[str], assemblies: Path, output: Path, generator: Path | None = None) -> None: + """Runs the C# to Python generator for just these classes (and what they refer to) over a folder of built assemblies.""" + dll = generator or default_generator() + if not dll.exists(): + raise AlignError(f"the generator was not found at {dll}; {BUILD_HINT}") + if not assemblies.is_dir(): + raise AlignError(f"{assemblies} is not a folder; pass the folder with the built oM dlls of the merged C#") + + output.mkdir(parents=True, exist_ok=True) + command = ["dotnet", str(dll), "--output", str(output), "--assemblies", str(assemblies), "--types", ",".join(types)] + completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", timeout=600) + if completed.returncode != 0: + raise AlignError(f"the generator failed: {(completed.stderr or completed.stdout).strip()}") diff --git a/.ci/generation/py2cs/check.py b/.ci/generation/py2cs/check.py new file mode 100644 index 0000000..db88805 --- /dev/null +++ b/.ci/generation/py2cs/check.py @@ -0,0 +1,49 @@ +"""Strict reading of a bhom_schema folder: every problem a hand-written class has, before any C# is drafted.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from py2cs.model import TypeInfo +from py2cs.reader import ReadError, read_package + +@dataclass +class CheckResult: + errors: list[ReadError] = field(default_factory=lambda: []) + warnings: list[str] = field(default_factory=lambda: []) + +def description_warnings(before: TypeInfo | None, after: TypeInfo) -> list[str]: + """Warns about a new class, or a new property of a class, that has no description, because BHoM requires one on every oM class and property. + + `check_package` calls it for the classes a Python change adds or modifies, so `py2cs check` reports them before a C# draft is made. + """ + warnings: list[str] = [] + if before is None and not after.description: + warnings.append(f"{after.simple_name} has no description; add a docstring (BHoM requires one on every oM class)") + known = {p.name for p in before.properties} if before is not None else set[str]() + for prop in after.properties: + if prop.name not in known and not prop.description: + warnings.append(f"{after.simple_name}.{prop.name} has no description; add Field(description=...) (BHoM requires one on every oM property)") + return warnings + +def check_package(root: Path, base_root: Path | None = None) -> CheckResult: + """Reads a bhom_schema folder strictly and collects every problem of the hand-written Python (errors) and every missing description or worked-out marker (warnings), without drafting any C#. + + The `check` command and the PR checks workflow run it first. With base_root (the package before the change) the new classes and properties are checked + for descriptions; without it, only classes that have no written __bhom__ marker are, since those were written by hand. + """ + result = CheckResult() + head = read_package(root, result.errors) + base = read_package(base_root, []) if base_root is not None else None + for clr_name in sorted(head): + info = head[clr_name] + warnings = list(info.notes) + if base is not None: + before = base.get(clr_name) + if before is None or before != info: + warnings += description_warnings(before, info) + elif info.notes: + warnings += description_warnings(None, info) + result.warnings += [f"{info.relative_path}: {w}" for w in warnings] + return result diff --git a/.ci/generation/py2cs/ci.py b/.ci/generation/py2cs/ci.py new file mode 100644 index 0000000..72ade58 --- /dev/null +++ b/.ci/generation/py2cs/ci.py @@ -0,0 +1,90 @@ +"""Helpers for the GitHub workflows: which repositories a Python change touches, and a check of a generated package.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from py2cs.compare import compare_manifests +from py2cs.diff import diff_manifests +from py2cs.github import MockGitHub, MockRepository, GitHubClient, RestClient +from py2cs.model import Manifest, load_manifest +from py2cs.reader import read_package +from py2cs.resolve import Listing, ResolveError, resolve_repo + +def load_allowed(path: Path) -> Listing: + """Reads Converted-repositories.txt, the list of repositories that C# pull requests may be raised in, as a repository listing. + + The `repos` and `plan` commands use it to refuse any class whose `__bhom__` names another repository, so a Python pull request cannot aim + a draft pull request at a repository the GitHub App can reach but that is not part of the loop. One Owner/Name per line, # starts a comment. + """ + repos = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.strip().startswith("#")] + return {repo: [] for repo in repos} + +def affected_repos(base: Manifest, head: Manifest, listing: Listing | None) -> tuple[list[str], list[str]]: + """Lists the C# repositories that own the classes a Python change adds, removes or modifies, and the classes whose repository cannot be resolved or is not allowed. + + The `repos` command prints the list as the matrix of the linked-PR workflow, which raises one draft pull request per repository. + """ + repos: set[str] = set() + problems: list[str] = [] + for change in diff_manifests(base, head).changes: + info = head.get(change.clr_name) or base[change.clr_name] + try: + repos.add(resolve_repo(info, listing).repo) + except ResolveError as e: + problems.append(str(e)) + return sorted(repos), problems + +IMPORT_ALL = """ +import importlib, json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +failures = [] +count = 0 +for path in sorted((root / "bhom_schema").rglob("*.py")): + if path.name in ("__init__.py", "_version.py") or "_support" in path.parts: + continue + count += 1 + module = ".".join(path.relative_to(root).with_suffix("").parts) + try: + importlib.import_module(module) + except Exception as error: + failures.append(f"{module}: {type(error).__name__}: {str(error)[:150]}") +print(json.dumps({"count": count, "failures": failures})) +""" + +def verify_package(package: Path, manifest: Path | None = None) -> tuple[int, list[str]]: + """Checks a generated package: imports every module, and compares what py2cs reads back from the files with the C# manifest. Returns the number of modules and the problems. + + The `verify` command runs it in the weekly generation workflow before anything is committed, so a package that does not import or does not match the C# is never committed. + The imports run in a separate interpreter, so the checked package does not mix with the one installed in this environment. + """ + problems: list[str] = [] + environment = dict(os.environ, PYTHONPATH=str(package.parent)) + done = subprocess.run([sys.executable, "-I", "-c", IMPORT_ALL, str(package.parent)], capture_output=True, text=True, env=environment, timeout=900) + count = 0 + try: + found = json.loads(done.stdout.strip().splitlines()[-1]) + count = int(found["count"]) + problems += [f"import failed: {f}" for f in found["failures"]] + except (ValueError, IndexError, KeyError): + problems.append(f"the import check did not run: {(done.stderr or done.stdout).strip()[:300]}") + + if manifest is not None: + problems += compare_manifests(load_manifest(manifest), read_package(package), allow_extra=False) + return count, problems + +def make_client(token_env: str | None, offline_repos: list[str] | None = None) -> tuple[GitHubClient, bool]: + """Creates the GitHub client for the `pr`, `close` and `status` commands from a token held in an environment variable, and says whether it is an offline stand-in. + + The token is never on a command line. Without a token and with offline_repos, a MockGitHub with empty repositories stands in, so a dry run on a laptop prints what would be done without calling GitHub. + """ + token = os.environ.get(token_env or "", "") + if token: + return RestClient(token), False + if offline_repos is not None: + return MockGitHub({repo: MockRepository() for repo in offline_repos}), True + raise ValueError(f"the environment variable {token_env} holds no token") diff --git a/.ci/generation/py2cs/compare.py b/.ci/generation/py2cs/compare.py new file mode 100644 index 0000000..95585c2 --- /dev/null +++ b/.ci/generation/py2cs/compare.py @@ -0,0 +1,127 @@ +"""Structural comparison of two manifests (for example C# reflection against what is read back from the Python).""" + +from __future__ import annotations + +import ast +import inspect +import re + +from py2cs.model import Manifest, PropertyInfo, TypeInfo + + +def clean(text: str | None) -> str | None: + """Normalises a docstring-style text (line endings, indentation, outer blanks) so formatting differences do not count; None stays None. + + Used when comparing and diffing descriptions. + """ + return None if text is None else inspect.cleandoc(text.replace("\r\n", "\n").replace("\r", "\n")).strip() + + +def same_collections(text: str) -> str: + """Rewrites collection types in a base-type string (IList, ReadOnlyDictionary, ISet and so on) to the default List, Dictionary or HashSet. + + Bases carry no ClrType marker, so `compare_types` needs this to compare C# bases with the Python read back. + """ + text = re.sub(r"System\.Collections\.(Generic|ObjectModel)\.(I?ReadOnly|I|Sorted)?(Dictionary|SortedList)<", "System.Collections.Generic.Dictionary<", text) + text = re.sub(r"System\.Collections\.(Generic|ObjectModel)\.(I?ReadOnly|I|Sorted)?(HashSet|Set)<", "System.Collections.Generic.HashSet<", text) + return re.sub( + r"System\.Collections\.(Generic|ObjectModel)\.(IList|ICollection|IEnumerable|IReadOnlyList|IReadOnlyCollection|ReadOnlyCollection|ObservableCollection|Collection|Queue|Stack|LinkedList)<", + "System.Collections.Generic.List<", + text, + ) + + +def normalise_default(text: str | None) -> str | None: + """Normalises a default expression by parsing and unparsing it, keeping a ` ` prefix; text that does not parse is returned unchanged. + + Lets `compare`, `diff` and `patch` ignore formatting differences between defaults. + """ + if text is None: + return None + prefix = " " if text.startswith(" ") else "" + body = text[len(prefix):] + try: + return prefix + ast.unparse(ast.parse(body, mode="eval")) + except SyntaxError: + return text + + +def compare_manifests(expected: Manifest, actual: Manifest, check_defaults: bool = True, allow_extra: bool = True, ignore: frozenset[str] = frozenset()) -> list[str]: + """Compares two manifests type by type and returns a list of problem texts: missing types, differences, and optionally unexpected extra types. + + Used by `ci` and `verify` to check that a generated package reads back as the C# manifest says. + """ + problems: list[str] = [] + for key, left in sorted(expected.items()): + right = actual.get(key) + if right is None: + problems.append(f"{key}: missing") + continue + problems.extend(compare_types(left, right, check_defaults, ignore)) + if not allow_extra: + problems.extend(f"{key}: unexpected type" for key in sorted(set(actual) - set(expected))) + return problems + + +def compare_types(left: TypeInfo, right: TypeInfo, check_defaults: bool = True, ignore: frozenset[str] = frozenset()) -> list[str]: + """Compares two TypeInfo objects field by field (kind, namespace, assembly, repo, path, bases, generics, description, properties, members) and returns the differences. + + `align` uses it to check that the Python of a pull request equals what the generator writes from the merged C#; labels in `ignore` are skipped. + """ + where = left.clr_name + problems: list[str] = [] + + def check(label: str, a: object, b: object) -> None: + """Adds a problem line when the two values differ, unless the label is ignored. + + Helper inside `compare_types` that reports one attribute at a time. + """ + if label not in ignore and a != b: + problems.append(f"{where}: {label} differs: {a!r} != {b!r}") + + check("kind", left.kind, right.kind) + check("namespace", left.namespace, right.namespace) + check("assembly", left.assembly, right.assembly) + check("repo", left.repo, right.repo) + check("path", left.relative_path, right.relative_path) + check("flags", left.flags, right.flags) + check("name", left.name, right.name) + check("bases", [same_collections(b) for b in left.bases], [same_collections(b) for b in right.bases]) + check("generic params", [(g.name, g.constraints) for g in left.generic_params], [(g.name, g.constraints) for g in right.generic_params]) + check("description", clean(left.description), clean(right.description)) + check("property names", [p.name for p in left.properties], [p.name for p in right.properties]) + check("members", [(m.name, m.value) for m in left.members], [(m.name, m.value) for m in right.members]) + + right_props = {p.name: p for p in right.properties} + for p in left.properties: + q = right_props.get(p.name) + if q is not None: + problems.extend(compare_properties(where, p, q, left.kind == "interface", check_defaults)) + return problems + + +def compare_properties(where: str, p: PropertyInfo, q: PropertyInfo, interface: bool, check_defaults: bool) -> list[str]: + """Compares one property of two types (C# type, description, default, Python name, quantity, display text, dynamic flag) and returns the differences. + + Called by `compare_types`; interface properties only compare type and description. + """ + problems: list[str] = [] + label = f"{where}.{p.name}" + if p.clr_type != q.clr_type: + problems.append(f"{label}: clr type differs: {p.clr_type!r} != {q.clr_type!r}") + if clean(p.description) != clean(q.description): + problems.append(f"{label}: description differs") + if not interface: + if p.has_default != q.has_default: + problems.append(f"{label}: has_default differs: {p.has_default} != {q.has_default}") + elif check_defaults and normalise_default(p.default) != normalise_default(q.default): + problems.append(f"{label}: default differs: {p.default!r} != {q.default!r}") + if p.py_name != q.py_name and p.py_name and q.py_name: + problems.append(f"{label}: python name differs") + if p.quantity != q.quantity: + problems.append(f"{label}: quantity differs: {p.quantity} != {q.quantity}") + if p.display_text != q.display_text: + problems.append(f"{label}: display text differs") + if p.dynamic != q.dynamic: + problems.append(f"{label}: dynamic differs") + return problems diff --git a/.ci/generation/py2cs/csharp.py b/.ci/generation/py2cs/csharp.py new file mode 100644 index 0000000..bd3f60a --- /dev/null +++ b/.ci/generation/py2cs/csharp.py @@ -0,0 +1,569 @@ +"""C# source for a type manifest entry: whole new files. Existing files are changed member by member (see patch.py).""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field + +from py2cs.model import Manifest, PropertyInfo, TypeInfo + +COPYRIGHT = """/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ +""" +INTERFACE_TO_CLASS = { + "System.Collections.Generic.IList": "System.Collections.Generic.List", + "System.Collections.Generic.ICollection": "System.Collections.Generic.List", + "System.Collections.Generic.IEnumerable": "System.Collections.Generic.List", + "System.Collections.Generic.IReadOnlyList": "System.Collections.Generic.List", + "System.Collections.Generic.IReadOnlyCollection": "System.Collections.Generic.List", + "System.Collections.Generic.ISet": "System.Collections.Generic.HashSet", + "System.Collections.Generic.IDictionary": "System.Collections.Generic.Dictionary", + "System.Collections.Generic.IReadOnlyDictionary": "System.Collections.Generic.Dictionary", +} + + +# ------------------------------------------------------------------------------------------------ type names + +@dataclass +class TypeNode: + name: str + args: list["TypeNode"] = field(default_factory=lambda: []) + suffix: str = "" # "?" and array markers, in order + + +def parse_type(text: str) -> TypeNode: + """Parses C# type text such as `List?` or `double[,]` into a TypeNode tree, raising ValueError when text is left over. + + The drafting code uses it to shorten names and to read collection types. + """ + node, index = _parse(text, 0) + if index != len(text): + raise ValueError(f"cannot parse C# type {text!r}") + return node + + +def _parse(text: str, i: int) -> tuple[TypeNode, int]: + """Parses one type name, its generic arguments and its `?` or array suffixes starting at an index, and returns the node with the next index. + + Recursive worker of `parse_type`. + """ + start = i + while i < len(text) and (text[i].isalnum() or text[i] in "._"): + i += 1 + node = TypeNode(text[start:i]) + if i < len(text) and text[i] == "<": + i += 1 + while True: + arg, i = _parse(text, i) + node.args.append(arg) + while text[i] == " ": + i += 1 + if text[i] == ",": + i += 1 + while text[i] == " ": + i += 1 + continue + break + assert text[i] == ">" + i += 1 + while i < len(text) and text[i] in "?[": + if text[i] == "?": + node.suffix += "?" + i += 1 + else: + end = text.index("]", i) + node.suffix += text[i:end + 1] + i = end + 1 + return node, i + + +@dataclass +class Names: + """Shortens qualified names to simple names and remembers the usings they need.""" + + namespace: str + ambiguous: frozenset[str] = frozenset() # simple names used by several types of the manifest: always written in full + usings: set[str] = field(default_factory=lambda: set[str]()) + _simple: dict[str, str] = field(default_factory=lambda: {}) + + def render(self, text: str) -> str: + """Writes C# type text with qualified names shortened to simple names, recording the usings they need. + + Used when drafting whole files and member changes so the code uses `using` directives like hand-written BHoM code. + """ + return self._render(parse_type(text)) + + def _render(self, node: TypeNode) -> str: + """Renders one TypeNode: shortens a qualified name unless its simple name is ambiguous or already taken by another namespace, and adds the namespace to the usings. + + Worker of `render`, also called with built nodes by `Defaults`. + """ + name = node.name + if "." in name: + namespace, simple = name.rsplit(".", 1) + known = self._simple.get(simple) + if simple in self.ambiguous: + pass + elif known is None or known == namespace: + self._simple[simple] = namespace + if namespace != self.namespace: + self.usings.add(namespace) + name = simple + text = name + if node.args: + text += "<" + ", ".join(self._render(a) for a in node.args) + ">" + return text + node.suffix + + +def csharp_string(text: str) -> str: + """Returns text as a C# string literal in double quotes, escaping backslashes, quotes and control characters. + + Used for `[Description]` and `[DisplayText]` attributes and string defaults in drafted C#. + """ + out = ['"'] + for c in text: + if c == "\\": + out.append("\\\\") + elif c == '"': + out.append('\\"') + elif c == "\n": + out.append("\\n") + elif c == "\r": + out.append("\\r") + elif c == "\t": + out.append("\\t") + elif ord(c) < 0x20: + out.append(f"\\u{ord(c):04x}") + else: + out.append(c) + out.append('"') + return "".join(out) + + +# ------------------------------------------------------------------------------------------------ defaults + +class Defaults: + def __init__(self, names: Names, manifest: Manifest): + """Stores the name shortener and manifest used to translate Python defaults into C# initialisers. + + Created per type by `render_type` and by `patch` for member-level changes. + """ + self.names = names + self.manifest = manifest + + def initialiser(self, prop: PropertyInfo) -> str | None: + """Translates the Python default of a property to C# initialiser text, or None when it has no default or cannot be translated. + + Used when drafting new files and new members; untranslatable defaults become reviewer notes. + """ + if not prop.has_default or prop.default is None: + return None + text = prop.default + try: + if text.startswith(" "): + return self._factory(text[len(" "):], prop) + return self._literal(ast.parse(text, mode="eval").body, prop) + except (ValueError, SyntaxError, KeyError): + return None + + # -- literals + + def _literal(self, node: ast.expr, prop: PropertyInfo) -> str | None: + """Translates a literal Python default (number, string, bool, Decimal, UUID, datetime, timedelta, enum member, signed number) to a C# expression; raises ValueError otherwise. + + Worker of `initialiser`. + """ + clr = prop.clr_type.rstrip("?") + if isinstance(node, ast.Constant): + value = node.value + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return f"'{value}'" if clr == "char" else csharp_string(value) + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return self._float(value, clr) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant): + return "-" + (self._literal(node.operand, prop) or "0") + if isinstance(node, ast.Call): + func = ast.unparse(node.func) + if func == "Decimal": + return f"{ast.literal_eval(node.args[0])}m" + if func == "float": + return {"nan": "double.NaN", "inf": "double.PositiveInfinity", "-inf": "double.NegativeInfinity"}[str(ast.literal_eval(node.args[0]))] + if func == "UUID": + if node.keywords: + return "Guid.Empty" + return f"new Guid({csharp_string(str(ast.literal_eval(node.args[0])))})" + if func == "datetime": + parts = [int(ast.literal_eval(a)) for a in node.args] + text = f"new DateTime({', '.join(str(p) for p in parts[:6])})" + if len(parts) > 6 and parts[6]: + text += f".AddTicks({parts[6] * 10})" + return text + if func == "timedelta": + if node.keywords: + seconds = float(ast.literal_eval(node.keywords[0].value)) + return f"TimeSpan.FromSeconds({int(seconds) if seconds == int(seconds) else seconds})" + return "TimeSpan.Zero" + enum = self.manifest.get(clr) + if enum is not None and enum.kind == "enum": + return f"({self.names.render(clr)}){ast.unparse(node.args[0])}" + if isinstance(node, ast.Attribute): + owner = ast.unparse(node.value) + if owner == "datetime" and node.attr == "min": + return "DateTime.MinValue" + enum = self.manifest.get(clr) + if enum is not None and enum.kind == "enum": + member = node.attr + known = {m.name for m in enum.members} + if member not in known and member.endswith("_") and member[:-1] in known: + member = member[:-1] + return f"{self.names.render(clr)}.{member}" + raise ValueError(f"cannot translate default {ast.unparse(node)}") + + @staticmethod + def _float(value: float, clr: str) -> str: + """Formats a Python float as a C# literal, with infinities as `double` constants and an `f` suffix for `float` properties. + + Used by `_literal`. + """ + if value == float("inf"): + return "double.PositiveInfinity" + if value == float("-inf"): + return "double.NegativeInfinity" + text = repr(value).replace("e", "E") + if "." not in text and "E" not in text: + text += ".0" + return text + ("f" if clr == "float" else "") + + # -- factories + + def _factory(self, text: str, prop: PropertyInfo) -> str | None: + """Translates a `default_factory` (list, set, dict, uuid4, Color, datetime.now, or a lambda building a class, colour, collection or rectangular array) to a C# initialiser; raises ValueError otherwise. + + Worker of `initialiser` for fields whose default is built per instance. + """ + clr = prop.clr_type.rstrip("?") + node = ast.parse(text, mode="eval").body + if isinstance(node, ast.Name): + if node.id in ("list", "set", "dict"): + return self._new_collection(clr) + if node.id == "uuid4": + return "Guid.NewGuid()" + if node.id == "Color": + return "Color.Empty" + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id in ("list", "set", "dict"): + return self._new_collection(clr) + if isinstance(node, ast.Attribute) and ast.unparse(node) == "datetime.now": + return "DateTime.Now" + if isinstance(node, ast.Lambda): + body = node.body + if isinstance(body, ast.Call) and isinstance(body.func, ast.Name) and not body.args and not body.keywords: + return f"new {self.names.render(clr)}()" + if isinstance(body, ast.Call) and isinstance(body.func, ast.Name) and body.func.id == "Color": + values = {k.arg: ast.literal_eval(k.value) for k in body.keywords} + return f"Color.FromArgb({values['A']}, {values['R']}, {values['G']}, {values['B']})" + if isinstance(body, (ast.List, ast.Set, ast.Dict)) and not (body.elts if not isinstance(body, ast.Dict) else body.keys): + return self._new_collection(clr) + if isinstance(body, ast.List) and any(isinstance(e, ast.List) for e in body.elts): + node = parse_type(clr) + if node.suffix != "[,]" or not all(isinstance(e, ast.List) for e in body.elts): + return None + + element = TypeNode(node.name, node.args, "") + cell_type = PropertyInfo(name="", clr_type=self.names._render(element)) # pyright: ignore[reportPrivateUsage] + rows = ", ".join("{ " + ", ".join(self._literal(c, cell_type) or "default" for c in row.elts) + " }" for row in body.elts if isinstance(row, ast.List)) + return f"new {self.names._render(element)}[,] {{ {rows} }}" # pyright: ignore[reportPrivateUsage] + if isinstance(body, (ast.List, ast.Set)): + items = ", ".join(self._literal(e, PropertyInfo(name="", clr_type=self._element(clr))) or "null" for e in body.elts) + return f"new {self._concrete(clr)} {{ {items} }}" + raise ValueError(f"cannot translate factory {text}") + + def _concrete(self, clr: str) -> str: + """Returns the C# text of a type that can be constructed, replacing a collection interface with its usual class. + + Used by `_factory` and `_new_collection` to write `new ...`. + """ + node = parse_type(clr) + if node.suffix.startswith("["): + return self.names.render(clr) + replacement = INTERFACE_TO_CLASS.get(node.name) + if replacement: + node = TypeNode(replacement, node.args, node.suffix) + return self.names._render(node) # pyright: ignore[reportPrivateUsage] + + def _new_collection(self, clr: str) -> str: + """Returns a `new` expression for an empty collection of the given C# type: an empty array, a read-only wrapper around an empty one, or the plain class. + + Used by `_factory` for empty list, set and dict defaults. + """ + node = parse_type(clr) + if node.suffix.startswith("[") and "," not in node.suffix: + element = TypeNode(node.name, node.args, "") + return f"new {self.names._render(element)}[0]" # pyright: ignore[reportPrivateUsage] + for read_only, writable in (("ObjectModel.ReadOnlyDictionary<", "Generic.Dictionary<"), ("ObjectModel.ReadOnlyCollection<", "Generic.List<")): + if read_only in clr: + inner = clr.replace(read_only, writable, 1) + return f"new {self._concrete(clr)}(new {self._concrete(inner)}())" + + return f"new {self._concrete(clr)}()" + + @staticmethod + def _element(clr: str) -> str: + """Returns the text of the first generic argument of a type, or the type without its suffix, as the element type of a collection. + + Used by `_factory` to translate the items of a list or set default. + """ + node = parse_type(clr) + if node.args: + return _format(node.args[0]) + return _format(TypeNode(node.name, node.args, "")) + + +def _format(node: TypeNode) -> str: + """Writes a TypeNode back to C# text without shortening any name. + + Used by `Defaults._element` to build a type for translating collection items. + """ + text = node.name + if node.args: + text += "<" + ", ".join(_format(a) for a in node.args) + ">" + return text + node.suffix + + +# ------------------------------------------------------------------------------------------------ rendering + +def ambiguous_names(manifest: Manifest) -> frozenset[str]: + """Returns the simple type names that more than one type of the manifest uses. Writing them unqualified could bind to the wrong type, so they are always written in full. + + Passed to `Names` by `render_type` and by `patch`. + """ + owners: dict[str, set[str]] = {} + for info in manifest.values(): + owners.setdefault(info.simple_name, set()).add(info.clr_name.split("`")[0]) + return frozenset(name for name, full in owners.items() if len(full) > 1) + + +def render_type(info: TypeInfo, manifest: Manifest, notes: list[str] | None = None, read_only_required: bool = False) -> tuple[str, set[str]]: + """Returns the namespace block of C# for one type (enum, class, struct or interface) and the namespaces it needs. + + Fields without a default become settable properties without an initial value, unless read_only_required is set: they then become get-only properties set by a constructor, as in the immutable oM types they were generated from. `emit_new` calls it when py2cs drafts a whole new file. + """ + names = Names(info.namespace, ambiguous_names(manifest)) + defaults = Defaults(names, manifest) + usings: set[str] = set() + lines: list[str] = [] + indent = " " + + if info.description: + usings.add("System.ComponentModel") + lines.append(f"{indent}[Description({csharp_string(info.description)})]") + + if info.kind == "enum": + lines.extend(_enum(info, usings)) + else: + lines.extend(_class_like(info, manifest, names, defaults, usings, notes, read_only_required)) + + block = f"namespace {info.namespace}\n{{\n" + "\n".join(lines) + "\n}\n" + return block, usings | names.usings + + +def _enum(info: TypeInfo, usings: set[str]) -> list[str]: + """Returns the C# lines of an enum: `[Flags]` for flags enums, members with descriptions and explicit values when they are not simply sequential. + + Called by `render_type` for the enum kind. + """ + indent = " " + lines: list[str] = [] + if info.flags: + usings.add("System") + lines.append(f"{indent}[Flags]") + lines.append(f"{indent}public enum {info.simple_name}") + lines.append(f"{indent}{{") + sequential = all(m.value == str(i) for i, m in enumerate(info.members)) + for i, member in enumerate(info.members): + if member.description: + usings.add("System.ComponentModel") + lines.append(f"{indent * 2}[Description({csharp_string(member.description)})]") + value = "" if sequential and not info.flags else f" = {member.value}" + lines.append(f"{indent * 2}{member.name}{value}{',' if i < len(info.members) - 1 else ''}") + lines.append(f"{indent}}}") + return lines + + +def _class_like(info: TypeInfo, manifest: Manifest, names: Names, defaults: Defaults, usings: set[str], notes: list[str] | None, read_only_required: bool) -> list[str]: + """Returns the C# lines of a class, abstract class, struct or interface: header with bases and generic constraints, properties and, for required fields, a constructor. + + Called by `render_type`; adds reviewer notes for fields drafted without an initial value. + """ + indent = " " + lines: list[str] = [] + keyword = {"interface": "interface", "struct": "struct"}.get(info.kind, "class") + modifier = "abstract " if info.kind == "abstract" else "" + generics = f"<{', '.join(g.name for g in info.generic_params)}>" if info.generic_params else "" + bases = [names.render(b) for b in info.bases] + header = f"{indent}public {modifier}{keyword} {info.simple_name}{generics}" + if bases: + header += " : " + ", ".join(bases) + for param in info.generic_params: + if param.constraints: + header += f" where {param.name} : " + ", ".join(names.render(c) for c in param.constraints) + lines.append(header) + lines.append(f"{indent}{{") + + required = [p for p in info.properties if not p.has_default] if info.kind != "interface" and read_only_required else [] + if info.kind != "interface" and not read_only_required and notes is not None: + notes.extend(f"{p.name} has no default in Python, so it was drafted without an initial value" for p in info.properties if not p.has_default) + if info.properties: + lines.append(f"{indent * 2}/***************************************************/") + lines.append(f"{indent * 2}/**** Properties ****/") + lines.append(f"{indent * 2}/***************************************************/") + lines.append("") + for prop in info.properties: + lines.extend(_property(info, prop, names, defaults, usings, notes, read_only=prop in required)) + lines.append("") + if required: + lines.extend(_constructor(info, required, names)) + if info.properties: + lines.append(f"{indent * 2}/***************************************************/") + lines.append(f"{indent}}}") + return lines + + +CSHARP_KEYWORDS = {"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", "continue", "decimal", "default", + "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", + "implicit", "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", + "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static", + "string", "struct", "switch", "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", + "void", "volatile", "while"} + + +def identifier(name: str) -> str: + """Returns a property name, prefixed with `@` when it is a C# keyword. + + Used when writing properties and constructor assignments in drafted C#. + """ + return "@" + name if name in CSHARP_KEYWORDS else name + + +def parameter_name(name: str) -> str: + """Returns a property name as a constructor parameter name: first letter lower-cased, prefixed with `@` when it is a C# keyword. + + Used by `_constructor` when drafting get-only types. + """ + text = name[:1].lower() + name[1:] + return "@" + text if text in CSHARP_KEYWORDS else text + + +def _constructor(info: TypeInfo, required: list[PropertyInfo], names: Names) -> list[str]: + """Returns the C# lines of a constructor taking the required fields and assigning them to the get-only properties. + + Called by `_class_like` when `read_only_required` is set; types with required fields are immutable in the oM. + """ + indent = " " + parameters = ", ".join(f"{names.render(p.clr_type)} {parameter_name(p.name)}" for p in required) + lines = [f"{indent}/***************************************************/", + f"{indent}/**** Constructors ****/", + f"{indent}/***************************************************/", + "", + f"{indent}public {info.simple_name}({parameters})", + f"{indent}{{"] + lines += [f"{indent} {identifier(p.name)} = {parameter_name(p.name)};" for p in required] + lines += [f"{indent}}}", ""] + return lines + + +def property_attributes(prop: PropertyInfo, usings: set[str]) -> list[str]: + """Returns the C# attributes of a property, without the square brackets: Description, quantity, DisplayText and DynamicProperty, adding the usings they need. + + Used by `_property` for new files and by `patch` for new members of existing files. + """ + attributes: list[str] = [] + if prop.description: + usings.add("System.ComponentModel") + attributes.append(f"Description({csharp_string(prop.description)})") + if prop.quantity: + usings.add("BH.oM.Quantities.Attributes") + attributes.append(prop.quantity[0]) + if prop.display_text: + usings.add("BH.oM.Base.Attributes") + attributes.append(f"DisplayText({csharp_string(prop.display_text)})") + if prop.dynamic: + usings.add("BH.oM.Base.Attributes") + attributes.append("DynamicProperty") + return attributes + +def untranslated_default(prop: PropertyInfo, initialiser: str | None) -> str | None: + """Returns a reviewer note when a Python default has no C# translation, or None. + + Used when drafting new files and members so the pull request tells the reviewer to set the default by hand. + """ + if prop.has_default and initialiser is None and prop.default not in (None, "None"): + return f"the default {prop.default} of {prop.name} could not be translated to C#; set it by hand" + return None + +def _property(info: TypeInfo, prop: PropertyInfo, names: Names, defaults: Defaults, usings: set[str], notes: list[str] | None, read_only: bool = False) -> list[str]: + """Returns the C# lines of one property: its attributes and either an interface accessor or a `public virtual` property with an initialiser translated from the Python default. + + Called by `_class_like` for each field of a new type. + """ + indent = " " + lines = [f"{indent}[{a}]" for a in property_attributes(prop, usings)] + + type_text = names.render(prop.clr_type) + if info.kind == "interface": + lines.append(f"{indent}{type_text} {identifier(prop.name)} {{ get; set; }}") + return lines + + modifier = "" if info.kind == "struct" else "virtual " + init = defaults.initialiser(prop) + suffix = f" = {init};" if init is not None else "" + accessors = "{ get; }" if read_only else "{ get; set; }" + lines.append(f"{indent}public {modifier}{type_text} {identifier(prop.name)} {accessors}{suffix}") + note = untranslated_default(prop, init) + if note is not None and notes is not None: + notes.append(note) + return lines + + +# ------------------------------------------------------------------------------------------------ files + +def _using_lines(namespaces: set[str]) -> list[str]: + """Returns sorted `using` lines for a set of namespaces, with System namespaces last. + + Used by `emit_new` to write the top of a drafted file. + """ + return [f"using {ns};" for ns in sorted(namespaces, key=lambda n: (n.startswith("System"), n))] + + +def emit_new(info: TypeInfo, manifest: Manifest, notes: list[str] | None = None, read_only_required: bool = False) -> str: + """Returns the text of a whole new C# file (copyright header, usings and namespace block) for a type that does not exist yet. Reviewer notes are added to notes. + + The `plan` step uses it for added types, and the `plan` command writes the result into the draft C# repository. + """ + block, namespaces = render_type(info, manifest, notes, read_only_required) + usings = "\n".join(_using_lines(namespaces)) + return COPYRIGHT + "\n" + (usings + "\n\n" if usings else "") + block diff --git a/.ci/generation/py2cs/diff.py b/.ci/generation/py2cs/diff.py new file mode 100644 index 0000000..3927087 --- /dev/null +++ b/.ci/generation/py2cs/diff.py @@ -0,0 +1,157 @@ +"""What changed between two manifests (the Python at the PR base and at the PR head).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from py2cs.compare import clean, normalise_default +from py2cs.model import Manifest, PropertyInfo, TypeInfo + + +@dataclass +class TypeChange: + clr_name: str + kind: str # added | removed | modified + details: list[str] = field(default_factory=lambda: []) + # The details that break existing C# code or saved data and may need a versioning upgrade + breaking: list[str] = field(default_factory=lambda: []) + + +@dataclass +class Diff: + changes: list[TypeChange] = field(default_factory=lambda: []) + + def __bool__(self) -> bool: + """Returns True when there is at least one change. + + Lets the workflow ask whether a pull request changes the schema. + """ + return bool(self.changes) + + def to_json(self) -> dict[str, Any]: + """Converts the diff to a JSON-ready dictionary of changes with their details and breaking items. + + The `diff` command writes it so later workflow jobs can read it. + """ + return {"changes": [{"clrName": c.clr_name, "kind": c.kind, "details": c.details, "breaking": c.breaking} for c in self.changes]} + + @staticmethod + def from_json(data: dict[str, Any]) -> "Diff": + """Rebuilds a Diff from the dictionary that `to_json` wrote. + + Lets later steps of the linked-PR workflow read a saved diff. + """ + return Diff([TypeChange(c["clrName"], c["kind"], list(c["details"]), list(c.get("breaking", []))) for c in data["changes"]]) + + +def diff_manifests(base: Manifest, head: Manifest) -> Diff: + """Compares the Python manifest at the pull request base with the one at its head and returns a Diff of added, removed and modified types with details and breaking changes. + + The start of the Python to C# flow: `plan`, `report`, `align` and the repos job all work from this result. + """ + changes: list[TypeChange] = [] + for key in sorted(set(base) | set(head)): + before, after = base.get(key), head.get(key) + if before is None: + changes.append(TypeChange(key, "added", ["new type"])) + elif after is None: + changes.append(TypeChange(key, "removed", ["type removed"], ["type removed"])) + else: + details = type_details(before, after) + if details: + changes.append(TypeChange(key, "modified", details, breaking_changes(before, after))) + return Diff(changes) + + +def describe(p: PropertyInfo) -> str: + """Returns a property as ` ` for use in change messages. + + Used by `type_details` and `breaking_changes`. + """ + return f"{p.clr_type} {p.name}" + + +def type_details(before: TypeInfo, after: TypeInfo) -> list[str]: + """Lists every change between two versions of a type: kind, bases, generics, description, properties added, removed or changed, and enum members. + + Called by `diff_manifests`; the text goes into the pull request description. + """ + details: list[str] = [] + if before.kind != after.kind: + details.append(f"kind changed from {before.kind} to {after.kind}") + if before.bases != after.bases: + details.append(f"bases changed from [{', '.join(before.bases)}] to [{', '.join(after.bases)}]") + if [(g.name, g.constraints) for g in before.generic_params] != [(g.name, g.constraints) for g in after.generic_params]: + details.append("generic parameters changed") + if clean(before.description) != clean(after.description): + details.append("description changed") + + old = {p.name: p for p in before.properties} + new = {p.name: p for p in after.properties} + for name in new: + if name not in old: + details.append(f"property added: {describe(new[name])}") + for name in old: + if name not in new: + details.append(f"property removed: {describe(old[name])}") + for name in new: + if name in old: + a, b = old[name], new[name] + if a.clr_type != b.clr_type: + details.append(f"property {name} type changed from {a.clr_type} to {b.clr_type}") + if a.has_default != b.has_default or normalise_default(a.default) != normalise_default(b.default): + details.append(f"property {name} default changed from {a.default} to {b.default}") + if clean(a.description) != clean(b.description): + details.append(f"property {name} description changed") + if a.quantity != b.quantity or a.display_text != b.display_text or a.dynamic != b.dynamic: + details.append(f"property {name} attributes changed") + + old_members = {m.name: m.value for m in before.members} + new_members = {m.name: m.value for m in after.members} + for name, value in new_members.items(): + if name not in old_members: + details.append(f"enum member added: {name} = {value}") + elif old_members[name] != value: + details.append(f"enum member {name} value changed from {old_members[name]} to {value}") + for name in old_members: + if name not in new_members: + details.append(f"enum member removed: {name}") + return details + + +def breaking_changes(before: TypeInfo, after: TypeInfo) -> list[str]: + """Lists the changes of a modified type that can break existing C# code or saved data. + + A removed property that has an added property of the same type to take its place is reported as a rename. That is a guess from the types, so a person confirms it; it is breaking either way. + """ + breaking: list[str] = [] + if before.kind != after.kind: + breaking.append(f"kind changed from {before.kind} to {after.kind}") + removed_bases = [b for b in before.bases if b not in after.bases] + if removed_bases: + breaking.append(f"base removed: {', '.join(removed_bases)}") + + old = {p.name: p for p in before.properties} + new = {p.name: p for p in after.properties} + removed = [p for name, p in old.items() if name not in new] + added = [p for name, p in new.items() if name not in old] + for prop in removed: + same_type_removed = [p for p in removed if p.clr_type == prop.clr_type] + same_type_added = [p for p in added if p.clr_type == prop.clr_type] + if len(same_type_removed) == 1 and len(same_type_added) == 1: + breaking.append(f"property renamed: {prop.name} to {same_type_added[0].name} ({prop.clr_type})") + else: + breaking.append(f"property removed: {describe(prop)}") + for name, prop in new.items(): + if name in old and old[name].clr_type != prop.clr_type: + breaking.append(f"property {name} type changed from {old[name].clr_type} to {prop.clr_type}") + + old_members = {m.name: m.value for m in before.members} + new_members = {m.name: m.value for m in after.members} + for name, value in old_members.items(): + if name not in new_members: + breaking.append(f"enum member removed: {name}") + elif new_members[name] != value: + breaking.append(f"enum member {name} value changed from {value} to {new_members[name]}") + return breaking diff --git a/.ci/generation/py2cs/github.py b/.ci/generation/py2cs/github.py new file mode 100644 index 0000000..b91a2d1 --- /dev/null +++ b/.ci/generation/py2cs/github.py @@ -0,0 +1,354 @@ +"""The GitHub calls py2cs needs, behind a small interface so everything above it can be tested without a network. + +RestClient talks to the REST API with urllib (the gh CLI is not needed). MockGitHub keeps repositories in memory, for tests and for +offline dry runs. +""" + +from __future__ import annotations + +import base64 +import json +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Protocol + +class GitHubError(Exception): + pass + +@dataclass +class PullRequest: + number: int + url: str + head: str + base: str + title: str = "" + body: str = "" + draft: bool = True + state: str = "open" + merged: bool = False + +@dataclass +class RemoteFile: + sha: str + content: bytes + +class GitHubClient(Protocol): + """What the linked-PR code uses. Every method acts on one repository, named Owner/Name.""" + + def default_branch(self, repo: str) -> str: + """Contract: returns the name of the default branch of the repository (Owner/Name). The linked-PR code uses it as the base of the draft C# pull request. + """ + + def branch_sha(self, repo: str, branch: str) -> str | None: + """Contract: returns the commit sha at the tip of the branch, or None when the branch does not exist. `pr` uses it to decide whether the branch python-schema/pr- must be created. + """ + + def create_branch(self, repo: str, branch: str, sha: str) -> None: + """Contract: creates the branch from the given commit sha. `pr` calls it once, the first time a Python PR is linked to a C# repository.""" + + def get_file(self, repo: str, ref: str, path: str) -> RemoteFile | None: + """Contract: returns the sha and bytes of a file at a branch or ref, or None when the file does not exist there. `pr` compares it with the planned file to skip unchanged files. + """ + + def put_file(self, repo: str, branch: str, path: str, content: bytes, message: str, sha: str | None) -> None: + """Contract: creates or updates one file on a branch with a commit message; the existing blob sha must be given when the file already exists. `pr` writes each planned C# file this way. + """ + + def find_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Contract: returns the open pull request whose head is the branch, or None. `pr` and `close` use it to find the linked draft C# pull request. + """ + + def create_pull_request(self, repo: str, head: str, base: str, title: str, body: str, draft: bool) -> PullRequest: + """Contract: opens a pull request from head into base and returns it; `draft` says whether it is a draft. `pr` opens the linked C# pull request as a draft. + """ + + def update_pull_request(self, repo: str, number: int, title: str, body: str) -> None: + """Contract: replaces the title and body of an existing pull request. `pr` calls it when a re-run of the plan changed the report.""" + + def add_labels(self, repo: str, number: int, labels: list[str]) -> None: + """Contract: adds labels to a pull request, keeping the ones it already has. `pr` uses it to add `breaking-change` to the C# pull request.""" + + def upsert_comment(self, repo: str, number: int, marker: str, body: str) -> None: + """Contract: writes a comment on a pull request, replacing the earlier comment that contains the marker text, or adding one when there is none. `pr` and `close` use it so a re-run does not pile up comments. + """ + + def close_pull_request(self, repo: str, number: int) -> None: + """Contract: closes a pull request without merging it. `close` calls it on the linked C# draft when its Python PR is closed unmerged.""" + + def find_linked_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Contract: returns the newest pull request from the branch whatever its state (open, closed or merged), or None. `status` uses it to see whether the linked C# pull request is merged. + """ + + def set_commit_status(self, repo: str, sha: str, state: str, context: str, description: str, url: str) -> None: + """Contract: sets a commit status (state, context, description, link) on a commit of the repository. `status` uses it to publish the state of the linked C# pull request on the Python PR. + """ + +# ------------------------------------------------------------------------------------------------ real client + +Transport = Callable[[str, str, dict[str, str], bytes | None], tuple[int, bytes]] + +def urllib_transport(method: str, url: str, headers: dict[str, str], body: bytes | None) -> tuple[int, bytes]: + """Sends one HTTP request with urllib and returns the status code and body; an HTTP error status is returned, not raised. + + The default transport of RestClient. Tests replace it with a fake so the client runs without a network. + """ + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.status, response.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + +class RestClient: + """The GitHub REST API. The token is only sent in the Authorization header; it is never written to output or kept in an error.""" + + def __init__(self, token: str, api_url: str = "https://api.github.com", transport: Transport | None = None): + """Creates a client for the GitHub REST API from a token, an optional API base url and an optional transport; raises GitHubError when the token is empty. + + Built by `make_client` in `ci` when a token environment variable is set. + """ + if not token: + raise GitHubError("no GitHub token was given") + self._token = token + self._api = api_url.rstrip("/") + self._transport = transport or urllib_transport + + def __repr__(self) -> str: + """Returns the API base url only, so the token never appears in logs or error output.""" + return f"RestClient({self._api})" + + def _request(self, method: str, path: str, body: dict[str, Any] | None = None, allow_missing: bool = False) -> Any: + """Sends one authenticated JSON request to the GitHub API and returns the decoded answer. + + A 404 gives None when allow_missing is set; any other status of 400 or more raises GitHubError with the message GitHub sent. Every method of the client goes through it. + """ + headers = {"Authorization": f"Bearer {self._token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "BHoM_PythonSchema-py2cs"} + data = json.dumps(body).encode("utf-8") if body is not None else None + if data is not None: + headers["Content-Type"] = "application/json" + status, content = self._transport(method, self._api + path, headers, data) + if status == 404 and allow_missing: + return None + if status >= 400: + message = "" + try: + message = str(json.loads(content).get("message", "")) + except ValueError: + pass + raise GitHubError(f"GitHub {method} {path} failed with {status}: {message}") + return json.loads(content) if content else None + + def default_branch(self, repo: str) -> str: + """Reads the default branch name of the repository from the GitHub API. Implements the contract for the linked-PR workflow.""" + return str(self._request("GET", f"/repos/{repo}")["default_branch"]) + + def branch_sha(self, repo: str, branch: str) -> str | None: + """Reads the commit sha of a branch from the GitHub API, or returns None when the branch does not exist. Used by `pr` to see whether the linked branch is already there. + """ + found = self._request("GET", f"/repos/{repo}/git/ref/heads/{urllib.parse.quote(branch, safe='/')}", allow_missing=True) + return None if found is None else str(found["object"]["sha"]) + + def create_branch(self, repo: str, branch: str, sha: str) -> None: + """Creates a branch at a commit sha through the GitHub refs API. Used by `pr` to start the branch python-schema/pr- in a C# repository.""" + self._request("POST", f"/repos/{repo}/git/refs", {"ref": f"refs/heads/{branch}", "sha": sha}) + + def get_file(self, repo: str, ref: str, path: str) -> RemoteFile | None: + """Reads a file at a ref through the GitHub contents API, decoding its base64 content, or returns None when it is missing. Used by `pr` to skip planned files that are already on the branch. + """ + found = self._request("GET", f"/repos/{repo}/contents/{urllib.parse.quote(path)}?ref={urllib.parse.quote(ref, safe='')}", allow_missing=True) + if found is None: + return None + return RemoteFile(str(found["sha"]), base64.b64decode(str(found["content"]))) + + def put_file(self, repo: str, branch: str, path: str, content: bytes, message: str, sha: str | None) -> None: + """Creates or updates a file on a branch through the GitHub contents API, sending the old blob sha when the file exists. Used by `pr` to push each planned C# file. + """ + body: dict[str, Any] = {"message": message, "content": base64.b64encode(content).decode("ascii"), "branch": branch} + if sha is not None: + body["sha"] = sha + self._request("PUT", f"/repos/{repo}/contents/{urllib.parse.quote(path)}", body) + + def find_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Looks up the open pull request whose head is the given branch of the same owner, or returns None. Used by `pr` and `close` to find the linked C# draft. + """ + owner = repo.split("/")[0] + found = self._request("GET", f"/repos/{repo}/pulls?state=open&head={urllib.parse.quote(owner + ':' + head_branch, safe='')}") + return self._pull_request(found[0]) if found else None + + def create_pull_request(self, repo: str, head: str, base: str, title: str, body: str, draft: bool) -> PullRequest: + """Opens a pull request through the GitHub API and returns it. Used by `pr` to open the linked C# pull request as a draft.""" + return self._pull_request(self._request("POST", f"/repos/{repo}/pulls", {"title": title, "head": head, "base": base, "body": body, "draft": draft})) + + def update_pull_request(self, repo: str, number: int, title: str, body: str) -> None: + """Replaces the title and body of a pull request through the GitHub API. Used by `pr` when a re-run changes the report.""" + self._request("PATCH", f"/repos/{repo}/pulls/{number}", {"title": title, "body": body}) + + def add_labels(self, repo: str, number: int, labels: list[str]) -> None: + """Adds labels to a pull request through the GitHub issues API. Used by `pr` to mark a C# pull request as a breaking change.""" + self._request("POST", f"/repos/{repo}/issues/{number}/labels", {"labels": labels}) + + def upsert_comment(self, repo: str, number: int, marker: str, body: str) -> None: + """Updates the first comment (among the first 100) that contains the marker, or posts a new comment when none does. + + Used by `pr` and `close` so a re-run keeps one comment instead of adding more. + """ + existing = self._request("GET", f"/repos/{repo}/issues/{number}/comments?per_page=100") or [] + for comment in existing: + if marker in str(comment.get("body", "")): + self._request("PATCH", f"/repos/{repo}/issues/comments/{comment['id']}", {"body": body}) + return + self._request("POST", f"/repos/{repo}/issues/{number}/comments", {"body": body}) + + def close_pull_request(self, repo: str, number: int) -> None: + """Closes a pull request through the GitHub API without merging. Used by `close` on the linked C# draft when the Python PR was closed unmerged. + """ + self._request("PATCH", f"/repos/{repo}/pulls/{number}", {"state": "closed"}) + + def find_linked_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Looks up the newest pull request from the branch, open, closed or merged, or returns None. + + Used by `status` to find out whether the linked C# pull request is merged. + """ + owner = repo.split("/")[0] + found = self._request("GET", f"/repos/{repo}/pulls?state=all&sort=created&direction=desc&head={urllib.parse.quote(owner + ':' + head_branch, safe='')}") + return self._pull_request(found[0]) if found else None + + def set_commit_status(self, repo: str, sha: str, state: str, context: str, description: str, url: str) -> None: + """Posts a commit status to the GitHub statuses API, cutting the description to the 140 characters GitHub allows. Used by `status` to show the state of the linked C# pull request on the Python PR. + """ + body = {"state": state, "context": context, "description": description[:140], "target_url": url} + self._request("POST", f"/repos/{repo}/statuses/{sha}", body) + + @staticmethod + def _pull_request(data: dict[str, Any]) -> PullRequest: + # The list endpoint has merged_at but not merged + """Converts a pull request object from the GitHub API into a PullRequest. + + It treats a pull request with merged_at as merged, because the list endpoints do not return the merged flag. + """ + merged = bool(data.get("merged") or data.get("merged_at")) + return PullRequest(int(data["number"]), str(data["html_url"]), str(data["head"]["ref"]), str(data["base"]["ref"]), str(data.get("title", "")), + str(data.get("body") or ""), bool(data.get("draft", False)), str(data.get("state", "open")), merged) + +# ------------------------------------------------------------------------------------------------ mock client + +@dataclass +class MockRepository: + default: str = "develop" + branches: dict[str, str] = field(default_factory=lambda: {"develop": "sha-0"}) + files: dict[tuple[str, str], bytes] = field(default_factory=lambda: {}) # (branch, path) -> content + pull_requests: list[PullRequest] = field(default_factory=lambda: []) + labels: dict[int, list[str]] = field(default_factory=lambda: {}) + comments: dict[int, list[str]] = field(default_factory=lambda: {}) + # (sha, context) -> (state, description, url) + statuses: dict[tuple[str, str], tuple[str, str, str]] = field(default_factory=lambda: {}) + +class MockGitHub: + """An in-memory GitHub. `calls` records every call that changes something, so tests can check what was (not) written.""" + + def __init__(self, repositories: dict[str, MockRepository] | None = None): + """Creates an in-memory GitHub from a dictionary of MockRepository by Owner/Name, with an empty log of calls. It stands in for GitHub in tests and offline dry runs. + """ + self.repositories = repositories if repositories is not None else {} + self.calls: list[tuple[str, ...]] = [] + self._numbers = 0 + + def _repo(self, repo: str) -> MockRepository: + """Returns the in-memory repository of that name, or raises GitHubError as GitHub does for a repository that is missing or has no App installed. + """ + if repo not in self.repositories: + raise GitHubError(f"{repo} was not found (or the App is not installed on it)") + return self.repositories[repo] + + def default_branch(self, repo: str) -> str: + """Returns the default branch of the in-memory repository. Stands in for GitHub in tests and offline dry runs.""" + return self._repo(repo).default + + def branch_sha(self, repo: str, branch: str) -> str | None: + """Returns the sha of a branch of the in-memory repository, or None. Stands in for GitHub in tests and offline dry runs.""" + return self._repo(repo).branches.get(branch) + + def create_branch(self, repo: str, branch: str, sha: str) -> None: + """Creates a branch at a sha and copies the files of the branch that sha belongs to onto it; the call is logged. Stands in for GitHub in tests and offline dry runs. + """ + self.calls.append(("create_branch", repo, branch)) + remote = self._repo(repo) + remote.branches[branch] = sha + for (source, path), content in list(remote.files.items()): + if source == self._branch_of_sha(remote, sha): + remote.files[(branch, path)] = content + + @staticmethod + def _branch_of_sha(remote: MockRepository, sha: str) -> str: + """Returns the name of the branch whose tip is the sha. Used by create_branch to copy the files of the branch it starts from.""" + return next(name for name, value in remote.branches.items() if value == sha) + + def get_file(self, repo: str, ref: str, path: str) -> RemoteFile | None: + """Returns the stored file at a branch with a sha made from its content, or None. Stands in for GitHub in tests and offline dry runs.""" + content = self._repo(repo).files.get((ref, path)) + return None if content is None else RemoteFile(f"blob-{hash(content)}", content) + + def put_file(self, repo: str, branch: str, path: str, content: bytes, message: str, sha: str | None) -> None: + """Stores a file on a branch and logs the call. Stands in for GitHub in tests and offline dry runs, and lets tests check which files were written. + """ + self.calls.append(("put_file", repo, branch, path)) + self._repo(repo).files[(branch, path)] = content + + def find_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Returns the open pull request with that head branch, or None. Stands in for GitHub in tests and offline dry runs.""" + return next((p for p in self._repo(repo).pull_requests if p.head == head_branch and p.state == "open"), None) + + def create_pull_request(self, repo: str, head: str, base: str, title: str, body: str, draft: bool) -> PullRequest: + """Adds a numbered pull request to the repository, logs the call and returns it. Stands in for GitHub in tests and offline dry runs.""" + self.calls.append(("create_pull_request", repo, head)) + self._numbers += 1 + pull = PullRequest(self._numbers, f"https://github.com/{repo}/pull/{self._numbers}", head, base, title, body, draft) + self._repo(repo).pull_requests.append(pull) + return pull + + def update_pull_request(self, repo: str, number: int, title: str, body: str) -> None: + """Replaces title and body of the pull request with that number and logs the call. Stands in for GitHub in tests and offline dry runs.""" + self.calls.append(("update_pull_request", repo, str(number))) + for pull in self._repo(repo).pull_requests: + if pull.number == number: + pull.title, pull.body = title, body + + def add_labels(self, repo: str, number: int, labels: list[str]) -> None: + """Adds the labels the pull request does not have yet and logs the call. Stands in for GitHub in tests and offline dry runs.""" + self.calls.append(("add_labels", repo, str(number))) + current = self._repo(repo).labels.setdefault(number, []) + current += [label for label in labels if label not in current] + + def upsert_comment(self, repo: str, number: int, marker: str, body: str) -> None: + """Replaces the stored comment that contains the marker, or appends a new one, and logs the call. Stands in for GitHub in tests and offline dry runs. + """ + self.calls.append(("upsert_comment", repo, str(number))) + comments = self._repo(repo).comments.setdefault(number, []) + for i, existing in enumerate(comments): + if marker in existing: + comments[i] = body + return + comments.append(body) + + def close_pull_request(self, repo: str, number: int) -> None: + """Marks the pull request with that number as closed and logs the call. Stands in for GitHub in tests and offline dry runs.""" + self.calls.append(("close_pull_request", repo, str(number))) + for pull in self._repo(repo).pull_requests: + if pull.number == number: + pull.state = "closed" + + def find_linked_pull_request(self, repo: str, head_branch: str) -> PullRequest | None: + """Returns the newest pull request (highest number) from the branch whatever its state, or None. Stands in for GitHub in tests and offline dry runs. + """ + matches = [p for p in self._repo(repo).pull_requests if p.head == head_branch] + return max(matches, key=lambda p: p.number) if matches else None + + def set_commit_status(self, repo: str, sha: str, state: str, context: str, description: str, url: str) -> None: + """Stores the status for the sha and context, cutting the description to 140 characters, and logs the call. Stands in for GitHub in tests and offline dry runs. + """ + self.calls.append(("set_commit_status", repo, sha, state)) + self._repo(repo).statuses[(sha, context)] = (state, description[:140], url) diff --git a/.ci/generation/py2cs/locate.py b/.ci/generation/py2cs/locate.py new file mode 100644 index 0000000..d017a9a --- /dev/null +++ b/.ci/generation/py2cs/locate.py @@ -0,0 +1,106 @@ +"""Finding C# files: where an existing type lives, and where a new one should go. Never derived from the namespace alone.""" + +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +from py2cs.model import TypeInfo + +SKIPPED_FOLDERS = {"obj", "bin", ".git", ".vs", "node_modules"} + + +@dataclass +class Located: + path: Path | None + note: str = "" + + +@dataclass +class Placement: + path: str # relative to the repository root, forward slashes + rule: str + + +def cs_files(folder: Path) -> list[Path]: + """Lists the .cs files under a folder, sorted, skipping build and tool folders such as obj, bin and .git. `locate` and `place` search these files. + """ + return sorted(p for p in folder.rglob("*.cs") if not (SKIPPED_FOLDERS & set(p.relative_to(folder).parts[:-1]))) + + +def read(path: Path) -> str: + """Reads a C# file as text, dropping a byte order mark and ignoring undecodable bytes. Used by `locate` and `place` to look at declarations and namespaces. + """ + return path.read_text(encoding="utf-8-sig", errors="ignore") + + +def assembly_folder(repo_root: Path, assembly: str) -> Path | None: + """Finds the folder of an assembly directly under the repository root, ignoring case, or returns None. `locate` and `place` search only inside it. + """ + for child in sorted(repo_root.iterdir()): + if child.is_dir() and child.name.lower() == assembly.lower(): + return child + return None + + +def declaration(name: str) -> re.Pattern[str]: + """Builds the regular expression that matches a class, interface, enum or struct declaration with that exact name. Used by `locate` to find the file of an existing type. + """ + return re.compile(rf"\b(?:class|interface|enum|struct)\s+{re.escape(name)}\b") + + +def namespace_of(text: str) -> str | None: + """Returns the namespace declared in C# source text, or None. `locate` and `place` use it to prefer files in the same namespace as the type.""" + match = re.search(r"^\s*namespace\s+([\w.]+)", text, re.M) + return match.group(1) if match else None + + +def locate(repo_root: Path, info: TypeInfo) -> Located: + """The existing .cs file declaring a type: file name first, then a search of the assembly folder for the declaration.""" + folder = assembly_folder(repo_root, info.assembly) or repo_root + pattern = declaration(info.simple_name) + + def matches(files: list[Path]) -> list[Path]: + """Keeps the files that declare the type, preferring those whose namespace is the type's namespace. Used by `locate` first for files named after the type, then for all files. + """ + found = [f for f in files if pattern.search(read(f))] + exact = [f for f in found if namespace_of(read(f)) == info.namespace] + return exact or found + + files = cs_files(folder) + found = matches([f for f in files if f.stem == info.simple_name]) or matches(files) + if not found: + return Located(None, "no existing declaration found") + if len(found) > 1: + return Located(found[0], f"{len(found)} files declare {info.simple_name}: {', '.join(f.name for f in found)}; the first was used") + return Located(found[0]) + + +def place(repo_root: Path, info: TypeInfo) -> Placement: + """Where to put the file for a new type, following the conventions already in the assembly folder.""" + folder = assembly_folder(repo_root, info.assembly) + file_name = f"{info.simple_name}.cs" + if folder is None: + return Placement(f"{info.assembly}/{file_name}", f"assembly folder {info.assembly} not found in the repository, placed in a new folder of that name") + + is_enum = info.kind == "enum" + siblings: list[tuple[Path, bool]] = [] + for path in cs_files(folder): + text = read(path) + if namespace_of(text) == info.namespace: + siblings.append((path, re.search(r"\benum\s+\w+", text) is not None)) + + same_kind = [p for p, enum in siblings if enum == is_enum] + for candidates, label in ((same_kind, "same kind"), ([p for p, _ in siblings], "any kind")): + if candidates: + folders = Counter(p.parent for p in candidates) + best = sorted(folders.items(), key=lambda kv: (-kv[1], len(kv[0].parts), str(kv[0])))[0][0] + return Placement((best.relative_to(repo_root) / file_name).as_posix(), f"folder of {len(candidates)} existing {label} files in namespace {info.namespace}") + + parts = info.namespace.removeprefix("BH.oM.Adapters.").removeprefix("BH.oM.").split(".")[1:] + candidate = folder.joinpath(*parts) + if parts and candidate.is_dir(): + return Placement((candidate.relative_to(repo_root) / file_name).as_posix(), "folder matching the namespace") + return Placement((folder.relative_to(repo_root) / file_name).as_posix(), "assembly root: no sibling types or matching folder found") diff --git a/.ci/generation/py2cs/model.py b/.ci/generation/py2cs/model.py new file mode 100644 index 0000000..59ef190 --- /dev/null +++ b/.ci/generation/py2cs/model.py @@ -0,0 +1,142 @@ +"""Language-neutral type manifest, the same JSON shape the C# generator writes.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class PropertyInfo: + name: str + clr_type: str + py_name: str = "" + has_default: bool = False + default: str | None = None + description: str | None = None + quantity: tuple[str, str] | None = None + display_text: str | None = None + dynamic: bool = False + + +@dataclass +class MemberInfo: + name: str + value: str + description: str | None = None + + +@dataclass +class GenericParam: + name: str + constraints: list[str] = field(default_factory=lambda: []) + + +@dataclass +class TypeInfo: + clr_name: str # BH.oM.Base.BHoMGroup`1 + name: str # canonical, BH.oM.Base.BHoMGroup + python_name: str + relative_path: str # Assembly/Folders/Name.py + namespace: str + assembly: str + kind: str # class | abstract | interface | enum | struct + repo: str | None = None + description: str | None = None + flags: bool = False + bases: list[str] = field(default_factory=lambda: []) + generic_params: list[GenericParam] = field(default_factory=lambda: []) + properties: list[PropertyInfo] = field(default_factory=lambda: []) + members: list[MemberInfo] = field(default_factory=lambda: []) + # Reviewer notes from reading the file (for example a worked-out marker); not part of the manifest + notes: list[str] = field(default_factory=lambda: [], compare=False) + + @property + def simple_name(self) -> str: + """C# type name without namespace and generic arity.""" + return self.clr_name.rsplit(".", 1)[-1].split("`")[0] + + +Manifest = dict[str, TypeInfo] + + +def from_json(data: dict[str, Any]) -> TypeInfo: + """Builds a TypeInfo from one type entry of the manifest JSON (camelCase keys) written by the C# generator or by `to_json`. + + `load_manifest` uses it to read the C# manifest that `align` and `verify` compare against. + """ + properties: list[PropertyInfo] = [] + for p in data.get("properties", []): + quantity = p.get("quantity") + properties.append(PropertyInfo( + name=p["name"], clr_type=p["clrType"], py_name=p.get("pyName", p["name"]), + has_default=p.get("hasDefault", False), default=p.get("default"), description=p.get("description"), + quantity=(quantity["category"], quantity["unit"]) if quantity else None, + display_text=p.get("displayText"), dynamic=p.get("dynamic", False))) + return TypeInfo( + clr_name=data["clrName"], name=data["name"], python_name=data["pythonName"], relative_path=data["relativePath"], + namespace=data["namespace"], assembly=data["assembly"], kind=data["kind"], repo=data.get("repo"), + description=data.get("description"), flags=data.get("flags", False), bases=list(data.get("bases", [])), + generic_params=[GenericParam(g["name"], list(g["constraints"])) for g in data.get("genericParams", [])], + properties=properties, + members=[MemberInfo(m["name"], m["value"], m.get("description")) for m in data.get("members", [])]) + + +def to_json(info: TypeInfo) -> dict[str, Any]: + """Converts a TypeInfo to the manifest JSON entry, leaving out empty optional fields and Python-only details for interfaces. + + `dump_manifest` uses it for the `manifest` command and for the Python manifest compared with the C# one. + """ + data: dict[str, Any] = { + "clrName": info.clr_name, "name": info.name, "pythonName": info.python_name, "relativePath": info.relative_path, + "namespace": info.namespace, "assembly": info.assembly, "kind": info.kind, "bases": info.bases, + } + if info.repo: + data["repo"] = info.repo + if info.description: + data["description"] = info.description + if info.flags: + data["flags"] = True + if info.generic_params: + data["genericParams"] = [{"name": g.name, "constraints": g.constraints} for g in info.generic_params] + props: list[dict[str, Any]] = [] + for p in info.properties: + entry: dict[str, Any] = {"name": p.name, "clrType": p.clr_type} + if info.kind != "interface": + entry["pyName"] = p.py_name + entry["hasDefault"] = p.has_default + if p.default is not None: + entry["default"] = p.default + if p.description: + entry["description"] = p.description + if p.quantity: + entry["quantity"] = {"category": p.quantity[0], "unit": p.quantity[1]} + if p.display_text: + entry["displayText"] = p.display_text + if p.dynamic: + entry["dynamic"] = True + props.append(entry) + data["properties"] = props + if info.kind == "enum": + data["members"] = [{"name": m.name, "value": m.value, **({"description": m.description} if m.description else {})} for m in info.members] + return data + + +def load_manifest(path: Path) -> Manifest: + """Reads a manifest JSON file into a dictionary of TypeInfo keyed by C# name. + + Used to load the manifest the C# generator wrote, so `align` and `verify` can compare it with the Python. + """ + data = json.loads(path.read_text(encoding="utf-8")) + return {t["clrName"]: from_json(t) for t in data["types"]} + + +def dump_manifest(manifest: Manifest, path: Path) -> None: + """Writes a manifest to a JSON file with types sorted by C# name. + + Used by the py2cs `manifest` command to save what was read from the Python package. + """ + types = [to_json(manifest[k]) for k in sorted(manifest)] + path.write_text(json.dumps({"schemaVersion": 1, "types": types}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") diff --git a/.ci/generation/py2cs/notes.py b/.ci/generation/py2cs/notes.py new file mode 100644 index 0000000..78489c7 --- /dev/null +++ b/.ci/generation/py2cs/notes.py @@ -0,0 +1,43 @@ +"""The release notes: what a Python user must know before upgrading to a new release.""" + +from __future__ import annotations + +from py2cs.diff import Diff + + +def render_notes(diff: Diff, version: str, previous: str | None) -> str: + """Returns Markdown release notes for a version: a Breaking changes section first, then the added, removed and modified classes. + + The release workflow writes it from the diff between the previous release and the new classes and attaches it to the GitHub Release. + """ + since = f"since {previous}" if previous else "(the first release)" + lines = [f"# BHoM schema {version}", "", f"Changes {since}.", ""] + + breaking = [(change.clr_name, item) for change in diff.changes for item in change.breaking] + lines.append("## Breaking changes") + lines.append("") + if breaking: + lines.append("Update your code for these before upgrading.") + lines.append("") + lines.extend(f"- `{name}`: {item}" for name, item in breaking) + else: + lines.append("None.") + lines.append("") + + for kind, title in (("added", "Added classes"), ("removed", "Removed classes"), ("modified", "Changed classes")): + changes = [change for change in diff.changes if change.kind == kind] + if not changes: + continue + + lines.append(f"## {title}") + lines.append("") + for change in changes: + if kind == "modified": + lines.append(f"- `{change.clr_name}`") + lines.extend(f" - {detail}" for detail in change.details) + else: + lines.append(f"- `{change.clr_name}`") + + lines.append("") + + return "\n".join(lines) diff --git a/.ci/generation/py2cs/patch.py b/.ci/generation/py2cs/patch.py new file mode 100644 index 0000000..86e92de --- /dev/null +++ b/.ci/generation/py2cs/patch.py @@ -0,0 +1,191 @@ +"""Member-level changes to an existing C# file: the change list sent to the Roslyn patcher, and the call to it. + +Only the members that changed in Python are touched. Everything else in the file (comments, formatting, attributes the +Python does not know about) is kept as it is. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from py2cs.compare import clean, normalise_default +from py2cs.csharp import Defaults, Names, ambiguous_names, csharp_string, property_attributes, untranslated_default +from py2cs.model import Manifest, PropertyInfo, TypeInfo + +PATCHER = Path(__file__).resolve().parents[1] / "PythonSchemaGeneration.Patcher" / "bin" / "Release" / "net8.0" / "PythonSchemaGeneration.Patcher.dll" +BUILD_HINT = "build it with: dotnet build .ci/generation/PythonSchemaGeneration.sln -c Release" + +class PatcherError(Exception): + pass + +@dataclass +class FilePatch: + """The changes for one existing C# file.""" + path: Path + type_name: str + namespace: str + changes: list[dict[str, Any]] = field(default_factory=lambda: []) + usings: list[str] = field(default_factory=lambda: []) + notes: list[str] = field(default_factory=lambda: []) + +@dataclass +class PatchResult: + text: str = "" + bom: bool = False + notes: list[str] = field(default_factory=lambda: []) + error: str | None = None + +def default_patcher() -> Path: + """The built patcher; the PY2CS_PATCHER environment variable points elsewhere.""" + configured = os.environ.get("PY2CS_PATCHER") + return Path(configured) if configured else PATCHER + +def run_patcher(patches: list[FilePatch], patcher: Path | None = None) -> list[PatchResult]: + """Runs the C# patcher once for every file. The input files are only read.""" + if not patches: + return [] + + dll = patcher or default_patcher() + if not dll.exists(): + raise PatcherError(f"the C# patcher was not found at {dll}; {BUILD_HINT}") + + with tempfile.TemporaryDirectory() as folder: + outputs = [Path(folder) / f"{i}.cs" for i in range(len(patches))] + job = {"files": [{"path": str(p.path), "output": str(o), "type": p.type_name, "namespace": p.namespace, "usings": p.usings, "changes": p.changes} + for p, o in zip(patches, outputs)]} + job_path = Path(folder) / "job.json" + job_path.write_text(json.dumps(job, indent=2), encoding="utf-8") + completed = subprocess.run(["dotnet", str(dll), str(job_path)], capture_output=True, text=True, encoding="utf-8", timeout=600) + if completed.returncode != 0: + raise PatcherError(f"the C# patcher failed: {(completed.stderr or completed.stdout).strip()}") + + results: list[PatchResult] = [] + for output, entry in zip(outputs, json.loads(completed.stdout)["files"]): + if entry.get("error"): + results.append(PatchResult(error=str(entry["error"]))) + continue + data = output.read_bytes() + results.append(PatchResult(data.decode("utf-8-sig"), data.startswith(b"\xef\xbb\xbf"), list(entry.get("notes") or []))) + return results + +def build_patch(path: Path, before: TypeInfo, after: TypeInfo, head: Manifest) -> FilePatch: + """The change list turning the C# of `before` into the C# of `after`.""" + ambiguous = ambiguous_names(head) + names = Names(after.namespace, ambiguous) + defaults = Defaults(names, head) + usings: set[str] = set() + patch = FilePatch(path, after.simple_name, after.namespace) + + def literal(text: str | None) -> str | None: + """Turns description text into a C# string literal and records that System.ComponentModel must be imported; returns None for empty text. Used for the description changes of types, properties and enum members. + """ + if not text: + return None + usings.add("System.ComponentModel") + return csharp_string(text) + + if before.kind != after.kind: + patch.notes.append(f"the kind changed from {before.kind} to {after.kind}; change the declaration by hand") + if [(g.name, g.constraints) for g in before.generic_params] != [(g.name, g.constraints) for g in after.generic_params]: + patch.notes.append("the generic parameters changed; change the declaration by hand") + if clean(before.description) != clean(after.description): + patch.changes.append({"kind": "setDescription", "description": literal(after.description)}) + if before.bases != after.bases: + # Removed bases are only matched by name, so they are rendered without collecting usings + removed = Names(after.namespace, ambiguous) + patch.changes.append({"kind": "setBases", + "remove": [removed.render(b) for b in before.bases if b not in after.bases], + "add": [names.render(b) for b in after.bases if b not in before.bases]}) + + old = {p.name: p for p in before.properties} + new = {p.name: p for p in after.properties} + anchor: str | None = None + for prop in after.properties: + if prop.name not in old: + patch.changes.append(_added_property(after, prop, anchor, names, defaults, usings, patch.notes)) + else: + patch.changes += _changed_property(old[prop.name], prop, names, defaults, usings, patch.notes, literal) + anchor = prop.name + for name in old: + if name not in new: + patch.changes.append({"kind": "removeProperty", "name": name}) + + old_members = {m.name: m for m in before.members} + new_members = {m.name for m in after.members} + anchor = None + for member in after.members: + if member.name not in old_members: + patch.changes.append({"kind": "addEnumMember", "name": member.name, "value": member.value, "description": literal(member.description), "after": anchor}) + else: + if old_members[member.name].value != member.value: + patch.changes.append({"kind": "setEnumValue", "name": member.name, "value": member.value}) + anchor = member.name + for name in old_members: + if name not in new_members: + patch.changes.append({"kind": "removeEnumMember", "name": name}) + + patch.usings = sorted(usings | names.usings) + return patch + +def _added_property(info: TypeInfo, prop: PropertyInfo, anchor: str | None, names: Names, defaults: Defaults, usings: set[str], notes: list[str]) -> dict[str, Any]: + """Builds the patcher change that adds a property after the anchor property, with its initialiser and attributes. + + It notes defaults that cannot be written in C# and properties without a Python default. Used by `build_patch` for properties new in Python. + """ + initialiser = defaults.initialiser(prop) if info.kind != "interface" else None + note = untranslated_default(prop, initialiser) if info.kind != "interface" else None + if note is not None: + notes.append(note) + if not prop.has_default and info.kind != "interface": + notes.append(f"{prop.name} has no default in Python, so it was added without an initial value") + return {"kind": "addProperty", "name": prop.name, "type": names.render(prop.clr_type), "initialiser": initialiser, + "add": property_attributes(prop, usings), "after": anchor} + +def _changed_property(a: PropertyInfo, b: PropertyInfo, names: Names, defaults: Defaults, usings: set[str], notes: list[str], literal: Callable[[str | None], str | None]) -> list[dict[str, Any]]: + """Builds the patcher changes for one property that exists in both versions: type, initialiser, description and the attribute changes (quantity, display text, dynamic). + + Used by `build_patch` for properties that changed in Python. + """ + changes: list[dict[str, Any]] = [] + if a.clr_type != b.clr_type: + changes.append({"kind": "setPropertyType", "name": b.name, "type": names.render(b.clr_type)}) + if a.has_default != b.has_default or normalise_default(a.default) != normalise_default(b.default): + initialiser = defaults.initialiser(b) + note = untranslated_default(b, initialiser) + if note is not None: + notes.append(note) + else: + changes.append({"kind": "setPropertyInitialiser", "name": b.name, "initialiser": initialiser}) + if clean(a.description) != clean(b.description): + changes.append({"kind": "setDescription", "name": b.name, "description": literal(b.description)}) + + remove: list[str] = [] + add: list[str] = [] + if a.quantity != b.quantity: + if a.quantity: + remove.append(a.quantity[0]) + if b.quantity: + usings.add("BH.oM.Quantities.Attributes") + add.append(b.quantity[0]) + if a.display_text != b.display_text: + if a.display_text: + remove.append("DisplayText") + if b.display_text: + usings.add("BH.oM.Base.Attributes") + add.append(f"DisplayText({csharp_string(b.display_text)})") + if a.dynamic != b.dynamic: + if a.dynamic: + remove.append("DynamicProperty") + else: + usings.add("BH.oM.Base.Attributes") + add.append("DynamicProperty") + if remove or add: + changes.append({"kind": "setAttributes", "name": b.name, "remove": remove, "add": add}) + return changes diff --git a/.ci/generation/py2cs/plan.py b/.ci/generation/py2cs/plan.py new file mode 100644 index 0000000..b2ee5dc --- /dev/null +++ b/.ci/generation/py2cs/plan.py @@ -0,0 +1,134 @@ +"""From a diff to the C# files to propose. Reads the C# clones, never writes into them.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from py2cs.check import description_warnings +from py2cs.csharp import emit_new +from py2cs.diff import Diff +from py2cs.locate import locate, place +from py2cs.model import Manifest +from py2cs.patch import FilePatch, PatcherError, PatchResult, build_patch, run_patcher +from py2cs.resolve import Listing, ResolveError, resolve_repo + +Patcher = Callable[[list[FilePatch]], list[PatchResult]] + +@dataclass +class PlannedFile: + repo: str + path: str # relative to the repository root + action: str # new | patch + clr_name: str + text: str + rule: str = "" + notes: list[str] = field(default_factory=lambda: []) + changes: list[str] = field(default_factory=lambda: []) + # The changes that can break existing C# code or saved data, for the "Breaking changes" section of the report + breaking: list[str] = field(default_factory=lambda: []) + # The existing file starts with a byte order mark, kept when the file is written + bom: bool = False + +@dataclass +class Plan: + files: list[PlannedFile] = field(default_factory=lambda: []) + removed: list[tuple[str, str]] = field(default_factory=lambda: []) # (repo or "?", clr name) + errors: list[str] = field(default_factory=lambda: []) + + def repos(self) -> list[str]: + """Returns the sorted repositories that have a planned file or a removed type with a known repository. `plan` writes one report per repository and `pr` raises one pull request per repository. + """ + return sorted({f.repo for f in self.files} | {r for r, _ in self.removed if r != "?"}) + + def labels(self) -> list[str]: + """Labels for the C# pull request: breaking-change when any file changes or removes something existing code may use.""" + return ["breaking-change"] if self.removed or any(f.breaking for f in self.files) else [] + + def summary(self) -> dict[str, Any]: + """Returns the plan as a dictionary of files, removed types, labels and errors for plan.json. `pr` reads it later to push the planned files. + """ + return { + "files": [{"repo": f.repo, "path": f.path, "action": f.action, "type": f.clr_name, "rule": f.rule, "notes": f.notes, "breaking": f.breaking} + for f in self.files], + "removed": [{"repo": r, "type": t} for r, t in self.removed], + "labels": self.labels(), + "errors": self.errors, + } + +def make_plan(diff: Diff, head: Manifest, base: Manifest, clone_for: Callable[[str], Path | None], listing: Listing | None = None, + patcher: Patcher | None = None, only_repo: str | None = None) -> Plan: + """Turns a diff of two Python versions into the planned C# files: whole new files for new types, member-level patches (through the Roslyn patcher) for types that already exist. + + It resolves the repository and finds the file in the clone of each class, and records problems instead of raising. Reads the clones, never writes into them. With only_repo, classes of other repositories are left out for the linked-PR job. The `plan` command calls it. + """ + plan = Plan() + pending: list[tuple[PlannedFile, FilePatch]] = [] + for change in diff.changes: + if change.kind == "removed": + info = base.get(change.clr_name) + repo = "?" + if info is not None: + try: + repo = resolve_repo(info, listing).repo + except ResolveError as e: + plan.errors.append(str(e)) + if only_repo is None or repo.lower() == only_repo.lower(): + plan.removed.append((repo, change.clr_name)) + continue + + info = head[change.clr_name] + try: + repo = resolve_repo(info, listing).repo + except ResolveError as e: + plan.errors.append(str(e)) + continue + if only_repo is not None and repo.lower() != only_repo.lower(): + continue + root = clone_for(repo) + if root is None: + plan.errors.append(f"{change.clr_name}: no checkout of {repo} is available") + continue + + notes = list(info.notes) + description_warnings(base.get(change.clr_name), info) + found = locate(root, info) + if found.path is not None: + relative = found.path.relative_to(root).as_posix() + if change.kind == "added": + plan.errors.append(f"{change.clr_name}: the type is new in Python, but {relative} in {repo} already declares {info.simple_name}; compare them by hand") + continue + patch = build_patch(found.path, base[change.clr_name], info, head) + if found.note: + notes.append(found.note) + if not patch.changes: + plan.errors += [f"{change.clr_name}: {n}" for n in patch.notes or ["nothing to change in C#"]] + continue + planned = PlannedFile(repo, relative, "patch", change.clr_name, "", "existing file found by searching for the declaration", + notes + patch.notes, change.details, change.breaking) + plan.files.append(planned) + pending.append((planned, patch)) + else: + placement = place(root, info) + if change.kind == "modified": + notes.append("the existing C# declaration was not found, so the type is drafted as a new file") + text = emit_new(info, head, notes) + plan.files.append(PlannedFile(repo, placement.path, "new", change.clr_name, text, placement.rule, notes, change.details, change.breaking)) + + if pending: + try: + results = (patcher or run_patcher)([p for _, p in pending]) + except PatcherError as e: + plan.errors.append(str(e)) + plan.files = [f for f in plan.files if f.action != "patch"] + return plan + + for (planned, _), result in zip(pending, results): + if result.error is not None: + plan.errors.append(f"{planned.clr_name}: {result.error}") + plan.files.remove(planned) + continue + planned.text, planned.bom = result.text, result.bom + planned.notes += result.notes + return plan diff --git a/.ci/generation/py2cs/pr.py b/.ci/generation/py2cs/pr.py new file mode 100644 index 0000000..879828b --- /dev/null +++ b/.ci/generation/py2cs/pr.py @@ -0,0 +1,159 @@ +"""Raising, updating and closing the draft C# pull requests that are linked to a Python pull request. + +Only the repositories named by the plan are touched, and every write goes through a GitHubClient, so a dry run changes nothing. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from py2cs.github import GitHubClient, PullRequest + +BRANCH_PREFIX = "python-schema/pr-" +# Hidden text that marks the comments py2cs writes, so a re-run updates them instead of adding new ones +MARKER = "" + +@dataclass +class PythonPr: + """The Python pull request the C# pull requests are linked to.""" + repo: str + number: int + url: str = "" + + @property + def reference(self) -> str: + """Returns the Python pull request as Owner/Name#number. Used in the C# pull request text and in the comments py2cs writes.""" + return f"{self.repo}#{self.number}" + +@dataclass +class Action: + kind: str + repo: str + detail: str = "" + + def __str__(self) -> str: + """Returns the action as one line of kind, repository and detail. `pr`, `close` and the command line print these lines, with `would` in front on a dry run. + """ + return f"{self.kind} {self.repo} {self.detail}".strip() + +@dataclass +class PrResult: + actions: list[Action] = field(default_factory=lambda: []) + pull_request: PullRequest | None = None + +def branch_name(number: int) -> str: + """Returns the branch python-schema/pr- that holds the C# draft for Python pull request N. `pr`, `close` and `status` all derive the branch from it. + """ + return f"{BRANCH_PREFIX}{number}" + +def plan_folder(plan_dir: Path, repo: str) -> Path: + """Returns the folder of a plan directory that holds the files planned for one repository (the slash of Owner/Name becomes a double underscore). Used by `pr` to read the files it pushes. + """ + return plan_dir / repo.replace("/", "__") + +def load_summary(plan_dir: Path) -> dict[str, Any]: + """Reads plan.json from a plan folder written by `plan`. `pr` uses it to know which files, labels and actions belong to which repository.""" + return json.loads((plan_dir / "plan.json").read_text(encoding="utf-8")) + +def title_for(python_pr: PythonPr, files: list[dict[str, Any]]) -> str: + """Builds the C# pull request title from the Python PR number and the class names of the planned files, naming at most three. Used by `pr` when it opens or updates the draft. + """ + names = sorted({str(f["type"]).rsplit(".", 1)[-1].split("`")[0] for f in files}) + shown = ", ".join(names[:3]) + (f" and {len(names) - 3} more" if len(names) > 3 else "") + return f"Python schema #{python_pr.number}: {shown}" + +def raise_pr(client: GitHubClient, plan_dir: Path, repo: str, python_pr: PythonPr, dry_run: bool = False, + python_client: GitHubClient | None = None) -> PrResult: + """Pushes the planned files of one C# repository to the branch python-schema/pr- and opens or updates a draft PR. + + Running it again for the same Python PR updates the same branch and the same PR. With dry_run nothing is written: the + actions are only listed (the reads that decide them still happen). + """ + result = PrResult() + summary = load_summary(plan_dir) + files = [f for f in summary["files"] if f["repo"].lower() == repo.lower()] + if not files: + result.actions.append(Action("skip", repo, "no planned files for this repository")) + return result + + branch = branch_name(python_pr.number) + base = client.default_branch(repo) + if client.branch_sha(repo, branch) is None: + base_sha = client.branch_sha(repo, base) + if base_sha is None: + raise ValueError(f"{repo} has no branch {base}") + result.actions.append(Action("create branch", repo, f"{branch} from {base}")) + if not dry_run: + client.create_branch(repo, branch, base_sha) + + # In a dry run a new branch does not exist yet, so the files are compared with the base branch it would start from + read_ref = base if dry_run and _new_branch(result) else branch + for entry in files: + data = (plan_folder(plan_dir, repo) / entry["path"]).read_bytes() + current = client.get_file(repo, read_ref, entry["path"]) + if current is not None and current.content == data: + result.actions.append(Action("unchanged", repo, entry["path"])) + continue + + result.actions.append(Action("write file", repo, f"{entry['path']} ({entry['action']})")) + if not dry_run: + message = f"Python schema #{python_pr.number}: {entry['action']} {str(entry['type']).rsplit('.', 1)[-1]}" + client.put_file(repo, branch, entry["path"], data, message, current.sha if current is not None else None) + + report_path = plan_dir / "reports" / (repo.replace("/", "__") + ".md") + body = report_path.read_text(encoding="utf-8") if report_path.exists() else f"Depends on {python_pr.reference}\n" + title = title_for(python_pr, files) + existing = client.find_pull_request(repo, branch) + if existing is not None: + result.pull_request = existing + if existing.body != body or existing.title != title: + result.actions.append(Action("update pull request", repo, f"#{existing.number}")) + if not dry_run: + client.update_pull_request(repo, existing.number, title, body) + else: + result.actions.append(Action("open draft pull request", repo, f"{branch} into {base}")) + if not dry_run: + result.pull_request = client.create_pull_request(repo, branch, base, title, body, True) + + labels: list[str] = list(summary.get("labels", [])) + if labels: + result.actions.append(Action("label", repo, ", ".join(labels))) + if not dry_run and result.pull_request is not None: + client.add_labels(repo, result.pull_request.number, labels) + + link = result.pull_request.url if result.pull_request is not None else f"(a draft pull request in {repo})" + result.actions.append(Action("comment on the Python pull request", python_pr.repo, f"#{python_pr.number} links {link}")) + if not dry_run and python_client is not None and result.pull_request is not None: + python_client.upsert_comment(python_pr.repo, python_pr.number, MARKER, f"{MARKER}\nDraft C# pull request: {link}\n") + return result + +def _new_branch(result: PrResult) -> bool: + """Tells whether the actions so far include creating the branch. In a dry run `raise_pr` uses it to compare files with the base branch, which the new branch would start from. + """ + return any(a.kind == "create branch" for a in result.actions) + +def close_linked(client: GitHubClient, repo: str, python_pr: PythonPr, merged: bool, dry_run: bool = False) -> PrResult: + """When the Python PR is closed: unmerged closes the linked C# PR with a comment, merged only comments on it.""" + result = PrResult() + found = client.find_pull_request(repo, branch_name(python_pr.number)) + if found is None: + result.actions.append(Action("skip", repo, f"no open linked pull request for {python_pr.reference}")) + return result + + result.pull_request = found + if merged: + text = f"{MARKER}\n{python_pr.reference} was merged." + result.actions.append(Action("comment", repo, f"#{found.number}: the Python pull request merged")) + else: + text = f"{MARKER}\n{python_pr.reference} was closed without merging, so this draft is closed too." + result.actions.append(Action("comment", repo, f"#{found.number}: the Python pull request was closed")) + result.actions.append(Action("close pull request", repo, f"#{found.number}")) + + if not dry_run: + client.upsert_comment(repo, found.number, MARKER, text) + if not merged: + client.close_pull_request(repo, found.number) + return result diff --git a/.ci/generation/py2cs/reader.py b/.ci/generation/py2cs/reader.py new file mode 100644 index 0000000..5510477 --- /dev/null +++ b/.ci/generation/py2cs/reader.py @@ -0,0 +1,652 @@ +"""Static reader: turns bhom_schema Python files into the type manifest. Files are parsed with ast, never imported. + +Generated files carry a __bhom__ marker. A hand-written class may leave it out: it is then worked out from the folder the +file is in and from the generated classes next to it (see infer_marker). +""" + +from __future__ import annotations + +import ast +import keyword +from collections import Counter +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from py2cs.model import GenericParam, Manifest, MemberInfo, PropertyInfo, TypeInfo + +ROOT_PACKAGE = "bhom_schema" +MODEL_BASES = {"SchemaModel", "BaseModel"} +ENUM_BASES = {"Enum", "IntEnum", "IntFlag", "Flag", "BHoMEnum", "BHoMFlags"} +FLAG_BASES = {"IntFlag", "Flag", "BHoMFlags"} +IGNORED_BASES = MODEL_BASES | ENUM_BASES | {"ABC"} + +PRIMITIVES = { + "str": "string", "int": "int", "float": "double", "bool": "bool", "Decimal": "decimal", + "datetime": "System.DateTime", "timedelta": "System.TimeSpan", "UUID": "System.Guid", "Any": "object", + "Color": "System.Drawing.Color", +} +VALUE_TYPES = {"int", "float", "bool", "Decimal", "datetime", "timedelta", "UUID", "Color"} +LIST = "System.Collections.Generic.List" +SET = "System.Collections.Generic.HashSet" +DICT = "System.Collections.Generic.Dictionary" +# Old typing names, written by people more often than by the generator +TYPING_ALIASES = {"List": "list", "Dict": "dict", "Set": "set", "Tuple": "tuple"} +SUPPORTED = "str, int, float, bool, Decimal, datetime, timedelta, UUID, Any, list[...], dict[...], set[...], tuple[...], X | None, or a bhom_schema class" + +@dataclass +class ReadError: + """One problem found in one file.""" + path: str + line: int + message: str + + def __str__(self) -> str: + """Formats the problem as `path:line: message`, leaving out the line when it is 0. + + The py2cs `check` command and the pull request report print each problem with it. + """ + return f"{self.path}:{self.line}: {self.message}" if self.line else f"{self.path}: {self.message}" + +class ReadErrors(ValueError): + """Every problem found while reading a package, raised together.""" + + def __init__(self, errors: list[ReadError]): + """Builds the exception from a list of ReadError, joining their text as the message and keeping the list on `errors`. + + `read_package` and `read_file` raise it when no errors list is passed in, so the caller sees every problem at once. + """ + super().__init__("\n".join(str(e) for e in errors)) + self.errors = errors + +@dataclass +class RefInfo: + """What a referenced class declares about itself in its __bhom__ marker (written or worked out).""" + module: str + marker: dict[str, Any] + + @property + def clr_name(self) -> str: + """Returns the full C# name (with generic arity) of the referenced class from its marker. + + The reader uses it to turn a Python import back into a C# type name. + """ + return str(self.marker["clr_name"]) + + @property + def base_name(self) -> str: + """Returns the C# name without the generic arity suffix, with nested-type `+` written as `.`. + + The reader builds the C# type text of a field or base class from it. + """ + return self.clr_name.split("`")[0].replace("+", ".") + + @property + def kind(self) -> str: + """Returns the kind of the referenced type (class, abstract, interface, enum or struct) from its marker, defaulting to class. + + The reader uses it to decide whether a referenced type is a value type and whether it counts as a model base. + """ + return str(self.marker.get("kind", "class")) + +@dataclass +class Inferred: + marker: dict[str, Any] + notes: list[str] + +class Resolver: + """Finds the marker of other modules, so a reference can be turned back into its C# name.""" + + def __init__(self, package_root: Path): + """Creates a resolver for a bhom_schema folder with empty caches for references, written markers and assembly markers. + + `read_package` makes one per run so each module is parsed once. + """ + self.root = package_root + self._refs: dict[str, RefInfo | None] = {} + self._written: dict[Path, dict[str, Any] | None] = {} + self._assemblies: dict[str, list[tuple[tuple[str, ...], dict[str, Any]]]] = {} + + def path_of(self, module: str) -> Path | None: + """Returns the file of a `bhom_schema.` module inside the package folder, or None when the module is outside the package or the file does not exist. + + First step of turning an import into the referenced class's C# name. + """ + if not module.startswith(ROOT_PACKAGE + "."): + return None + parts = module.split(".")[1:] + path = self.root.joinpath(*parts).with_suffix(".py") + return path if path.exists() else None + + def ref(self, module: str) -> RefInfo | None: + """Returns what the module of an imported bhom_schema class says about itself (its written marker, else a worked-out one) or None when it cannot be found. + + The Converter calls it to translate a field type or base class into a C# type name. A placeholder guards against classes that refer to each other. + """ + if module in self._refs: + return self._refs[module] + # A placeholder, so two hand-written classes naming each other cannot recurse forever + self._refs[module] = None + info: RefInfo | None = None + path = self.path_of(module) + if path is not None: + marker = self.written_marker(path) + if marker is None: + marker = self._inferred_marker(path) + if marker is not None: + info = RefInfo(module, marker) + self._refs[module] = info + return info + + def written_marker(self, path: Path) -> dict[str, Any] | None: + """Returns the `__bhom__` marker written in a file, or None when it has none or the file cannot be parsed. Results are cached. + + Generated files always carry one; hand-written classes usually do not. + """ + if path not in self._written: + try: + self._written[path] = find_marker(ast.parse(path.read_text(encoding="utf-8"))) + except (SyntaxError, ValueError): + self._written[path] = None + return self._written[path] + + def assembly_markers(self, assembly: str) -> list[tuple[tuple[str, ...], dict[str, Any]]]: + """Lists the written markers of every class in an assembly folder, each with its folders below the assembly folder. Results are cached. + + `infer_marker` uses them to work out the namespace and repository of a hand-written class from the generated classes around it. + """ + if assembly not in self._assemblies: + found: list[tuple[tuple[str, ...], dict[str, Any]]] = [] + folder = self.root / assembly + if folder.is_dir(): + for path in sorted(folder.rglob("*.py")): + if path.name == "__init__.py": + continue + marker = self.written_marker(path) + if marker is not None: + found.append((path.relative_to(folder).parts[:-1], marker)) + self._assemblies[assembly] = found + return self._assemblies[assembly] + + def _inferred_marker(self, path: Path) -> dict[str, Any] | None: + """Parses a file and works out the marker of its first class, returning None if the file has no class or the marker cannot be worked out. + + Used by `ref` when a referenced hand-written module has no written marker. + """ + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + return None + classes = [n for n in tree.body if isinstance(n, ast.ClassDef)] + if not classes: + return None + try: + return infer_marker(path, self.root, classes[0], collect_imports(tree), self).marker + except ValueError: + return None + +def find_marker(tree: ast.Module) -> dict[str, Any] | None: + """Finds the `__bhom__` annotated assignment in a parsed module and returns its literal value, or None when there is none. + + The reader's first look at a file: generated classes state their C# name, namespace and repository this way. + """ + for node in ast.walk(tree): + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "__bhom__" and node.value is not None: + return ast.literal_eval(node.value) + return None + +def collect_imports(tree: ast.Module) -> dict[str, tuple[str, str]]: + """Maps each name imported with `from ... import` in a parsed module to its (module, original name), using the alias as the key. + + The reader uses it to resolve aliased and cross-module references in annotations. + """ + imports: dict[str, tuple[str, str]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + imports[alias.asname or alias.name] = (node.module, alias.name) + return imports + +def name_of(node: ast.expr, imports: dict[str, tuple[str, str]]) -> str | None: + """Returns the original name behind a `Name` or `module.Name` expression, undoing import aliases, or None for other expressions. + + The reader uses it to recognise `Field`, `Annotated`, `ClassVar` and base classes however they were imported. + """ + if isinstance(node, ast.Name): + return imports.get(node.id, (None, node.id))[1] + if isinstance(node, ast.Attribute): + return node.attr + return None + +# ------------------------------------------------------------------------------------------------ inferring the marker + +def namespace_prefix(markers: list[tuple[tuple[str, ...], dict[str, Any]]]) -> str | None: + """Works out the C# namespace of an assembly folder itself from its generated classes, as the most common prefix once the class's folders are removed. + + Lets `infer_marker` give a hand-written class in a new subfolder the right namespace. + """ + prefixes: Counter[str] = Counter() + for folders, marker in markers: + segments = str(marker["namespace"]).split(".") + if not folders: + prefixes[".".join(segments)] += 1 + elif tuple(segments[-len(folders):]) == folders: + prefixes[".".join(segments[:-len(folders)])] += 1 + return prefixes.most_common(1)[0][0] if prefixes else None + +def infer_kind(cls: ast.ClassDef, imports: dict[str, tuple[str, str]], resolver: Resolver) -> tuple[str, bool]: + """Decides the kind (class, abstract, interface or enum) and whether it is a flags enum from the base classes of a class that has no marker. + + Part of working out a hand-written class's marker; raises ValueError when the class has no usable base. + """ + originals: set[str] = set() + referenced: list[str] = [] + for base in cls.bases: + head = base.value if isinstance(base, ast.Subscript) else base + original = name_of(head, imports) + if original is None: + continue + if original in ENUM_BASES: + return "enum", original in FLAG_BASES + originals.add(original) + module = imports.get(head.id, ("", ""))[0] if isinstance(head, ast.Name) else "" + if module.startswith(ROOT_PACKAGE + ".") and not module.startswith(ROOT_PACKAGE + "._support"): + ref = resolver.ref(module) + if ref is not None: + referenced.append(ref.kind) + if originals & MODEL_BASES or any(k in ("class", "abstract", "struct") for k in referenced): + return ("abstract" if "ABC" in originals else "class"), False + if originals: + return "interface", False + raise ValueError(f"{cls.name} has no base class; derive it from SchemaModel, a bhom_schema class, Enum or an interface") + +def infer_marker(path: Path, root: Path, cls: ast.ClassDef, imports: dict[str, tuple[str, str]], resolver: Resolver) -> Inferred: + """Works out the `__bhom__` marker of a hand-written class from its folder and the generated classes around it: namespace, repository, kind and C# name. + + Lets a developer add a class in a pull request without writing a marker; the reader records a note saying it was worked out. + """ + parts = path.relative_to(root).parts + if len(parts) < 2: + raise ValueError("the file must be inside an assembly folder, for example Structure_oM/Elements/.py") + assembly = parts[0] + folders = tuple(parts[1:-1]) + markers = resolver.assembly_markers(assembly) + if not markers: + raise ValueError(f"{assembly} is not an existing assembly folder (it has no generated class); put new classes in an existing assembly folder") + + siblings: Counter[str] = Counter(str(m["namespace"]) for f, m in markers if f == folders) + if siblings: + namespace = siblings.most_common(1)[0][0] + else: + prefix = namespace_prefix(markers) + if prefix is None: + raise ValueError(f"the namespace of the folders in {assembly} could not be worked out; add a __bhom__ marker") + namespace = ".".join([prefix, *folders]) + + repos: Counter[str] = Counter(str(m["repo"]) for _, m in markers if m.get("repo")) + repo = repos.most_common(1)[0][0] if repos else None + kind, flags = infer_kind(cls, imports, resolver) + + generics = [tp.name for tp in cls.type_params] + simple = cls.name + suffix = f"_{len(generics)}" + if generics and simple.endswith(suffix): + simple = simple[:-len(suffix)] + marker: dict[str, Any] = { + "clr_name": f"{namespace}.{simple}" + (f"`{len(generics)}" if generics else ""), + "assembly": assembly, + "namespace": namespace, + "kind": kind, + } + if repo: + marker["repo"] = repo + if flags: + marker["flags"] = True + if kind == "enum": + members = [s.targets[0].id for s in cls.body if isinstance(s, ast.Assign) and isinstance(s.targets[0], ast.Name)] + renamed = {m: m[:-1] for m in members if m.endswith("_") and keyword.iskeyword(m[:-1])} + if renamed: + marker["renamed_members"] = renamed + note = f"no __bhom__ marker was written, so it was worked out: C# name {marker['clr_name']}, repository {repo or 'unknown'}, kind {kind}" + return Inferred(marker, [note]) + +# ------------------------------------------------------------------------------------------------ annotations + +@dataclass +class Converter: + """Python annotation to canonical C# type text, the inverse of the C# TypeMapper.""" + imports: dict[str, tuple[str, str]] + type_params: set[str] + resolver: Resolver + self_marker: dict[str, Any] + self_name: str + metadata: list[ast.expr] = field(default_factory=lambda: []) + + def origin(self, local: str) -> tuple[str | None, str]: + """Returns the (module, original name) of an imported name, or (None, name) for builtins and local names. + + The Converter uses it to decide whether a name is a bhom_schema class or a primitive. + """ + if local in self.imports: + return self.imports[local] + return None, local + + def original(self, node: ast.expr) -> str: + """Returns the original name behind a Name or module.Name node, raising ValueError for any other expression. + + The Converter uses it to read the head of an annotation however it was imported. + """ + name = name_of(node, self.imports) + if name is None: + raise ValueError(f"unsupported expression: {ast.unparse(node)}") + return name + + def convert(self, node: ast.expr) -> tuple[str, bool]: + """Converts a Python annotation to canonical C# type text and says whether it is a value type on the Python side. Raises ValueError for unsupported annotations. + + The inverse of the C# TypeMapper: used by the reader for field types, base classes and type-parameter bounds. + """ + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return self.convert(ast.parse(node.value, mode="eval").body) + + if isinstance(node, ast.Subscript) and self.original(node.value) == "Annotated": + elements = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice] + self.metadata = elements[1:] + for meta in elements[1:]: + if isinstance(meta, ast.Call) and self.original(meta.func) == "ClrType": + return str(ast.literal_eval(meta.args[0])), False + return self.convert(elements[0]) + + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + for kept, other in ((node.left, node.right), (node.right, node.left)): + if is_none(other): + return self._nullable(kept) + raise ValueError(f"unsupported union: {ast.unparse(node)}; only 'X | None' is supported") + + if isinstance(node, ast.Name): + return self._named(node.id, []) + + if isinstance(node, ast.Attribute): + if node.attr in PRIMITIVES: + return PRIMITIVES[node.attr], node.attr in VALUE_TYPES + raise ValueError(f"unsupported type '{ast.unparse(node)}'; use {SUPPORTED}") + + if isinstance(node, ast.Subscript): + head = self.original(node.value) + elements = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice] + if head == "Optional": + return self._nullable(elements[0]) + if head == "Union": + kept = [e for e in elements if not is_none(e)] + if len(kept) == 1 and len(kept) < len(elements): + return self._nullable(kept[0]) + raise ValueError(f"unsupported union: {ast.unparse(node)}; only 'X | None' is supported") + if head == "Callable": + return "System.Delegate", False + if head in ("Polymorphic", "SkipJsonSchema"): + # Wrappers that only change how the field reads and writes JSON; for C# it is the plain type + return self.convert(elements[0]) + head = TYPING_ALIASES.get(head, head) + args = [self.convert(a)[0] for a in elements] + if head == "list": + return f"{LIST}<{args[0]}>", False + if head == "set": + return f"{SET}<{args[0]}>", False + if head == "dict": + return f"{DICT}<{args[0]}, {args[1]}>", False + if head == "tuple": + return f"System.Tuple<{', '.join(args)}>", False + if not isinstance(node.value, ast.Name): + raise ValueError(f"unsupported type '{ast.unparse(node)}'; use {SUPPORTED}") + return self._named(node.value.id, args) + + raise ValueError(f"unsupported annotation: {ast.unparse(node)}") + + def _nullable(self, node: ast.expr) -> tuple[str, bool]: + """Converts the type inside an optional annotation, adding `?` when it is a value type that is not already nullable. + + Handles `X | None`, `Optional[X]` and `Union[X, None]`. + """ + inner, value = self.convert(node) + return (inner + "?" if value and not inner.endswith("?") else inner), False + + def _named(self, local: str, args: list[str]) -> tuple[str, bool]: + """Converts a plain or subscripted class name to C# type text: a type parameter, the class itself, a bhom_schema class found through the resolver, or a primitive. + + Raises ValueError when the name cannot be resolved. + """ + if local in self.type_params: + return local, False + if local == self.self_name and local not in self.imports: + ref = RefInfo("", self.self_marker) + return self._constructed(ref, args), ref.kind in ("enum", "struct") + + module, original = self.origin(local) + if module is not None and module.startswith(ROOT_PACKAGE + ".") and not module.startswith(ROOT_PACKAGE + "._support"): + ref = self.resolver.ref(module) + if ref is None: + raise ValueError(f"cannot resolve {local} from {module}") + return self._constructed(ref, args), ref.kind in ("enum", "struct") + if original in PRIMITIVES: + return PRIMITIVES[original], original in VALUE_TYPES + raise ValueError(f"unsupported type '{local}'; use {SUPPORTED}") + + @staticmethod + def _constructed(ref: RefInfo, args: list[str]) -> str: + """Builds the C# text of a referenced class, adding generic arguments, or its own parameter names when it is generic and none are given. + + Called by `_named` once the referenced class is known. + """ + if args: + return f"{ref.base_name}<{', '.join(args)}>" + if "`" in ref.clr_name: + return f"{ref.base_name}<{', '.join(ref.marker.get('generic_params', {}).keys())}>" + return ref.base_name + +def is_none(node: ast.expr) -> bool: + """Returns True when an expression is the constant `None`. + + Used by the reader to spot `X | None` and `Optional` annotations. + """ + return isinstance(node, ast.Constant) and node.value is None + +def is_class_var(node: ast.expr, converter: Converter) -> bool: + """Returns True when an annotation is `ClassVar` (bare or subscripted), after resolving imports. + + The reader skips such attributes because they are not C# properties. + """ + head = node.value if isinstance(node, ast.Subscript) else node + return name_of(head, converter.imports) == "ClassVar" + +# ------------------------------------------------------------------------------------------------ files + +def read_file(path: Path, root: Path, resolver: Resolver | None = None, errors: list[ReadError] | None = None) -> TypeInfo | None: + """Reads one Python module into a TypeInfo, or None when it has no class or cannot be parsed. Problems go into `errors`, or are raised as ReadErrors when no list is given. + + Called by `read_package` for each file, and by the py2cs check for one file at a time. + """ + relative = path.relative_to(root).as_posix() + problems: list[ReadError] = [] + + def error(line: int, message: str) -> None: + """Records a problem with the file's relative path, a line and a message. + + Collects what `_read` reports so `read_file` can hand it on or raise it. + """ + problems.append(ReadError(relative, line, message)) + + info = _read(path, root, resolver or Resolver(root), error) + if errors is not None: + errors.extend(problems) + elif problems: + raise ReadErrors(problems) + return info + +def _read(path: Path, root: Path, resolver: Resolver, error: Callable[[int, str], None]) -> TypeInfo | None: + """Parses a module with ast and builds its TypeInfo: marker (written or worked out), generic parameters, bases, fields or enum members, and interface properties. Problems are passed to `error`. + + The working part of `read_file`; this is how the Python side of the schema becomes a manifest. + """ + source = path.read_text(encoding="utf-8") + try: + tree = ast.parse(source) + except SyntaxError as e: + error(e.lineno or 0, f"not valid Python: {e.msg}") + return None + + classes = [n for n in tree.body if isinstance(n, ast.ClassDef)] + if not classes: + return None + + for extra in classes[1:]: + error(extra.lineno, f"only one class per file is read; move {extra.name} to its own file") + + cls = classes[0] + imports = collect_imports(tree) + marker = find_marker(tree) + notes: list[str] = [] + if marker is None: + try: + inferred = infer_marker(path, root, cls, imports, resolver) + except ValueError as e: + error(cls.lineno, f"{cls.name} has no __bhom__ marker and it could not be worked out: {e}") + return None + marker, notes = inferred.marker, inferred.notes + + type_params = {tp.name for tp in cls.type_params} + kind = str(marker.get("kind", "class")) + converter = Converter(imports, type_params, resolver, marker, cls.name) + + generic_params: list[GenericParam] = [] + declared = marker.get("generic_params", {}) + for tp in cls.type_params: + constraints = list(declared.get(tp.name, [])) + if tp.name not in declared and isinstance(tp, ast.TypeVar) and tp.bound is not None: + try: + constraints = [converter.convert(tp.bound)[0]] + except ValueError as e: + error(cls.lineno, f"type parameter {tp.name}: {e}") + generic_params.append(GenericParam(tp.name, constraints)) + + base_name = str(marker["clr_name"]).split("`")[0].replace("+", ".") + canonical = base_name + (f"<{', '.join(p.name for p in generic_params)}>" if generic_params else "") + + bases: list[str] = [] + for base in cls.bases: + head = base.value if isinstance(base, ast.Subscript) else base + if name_of(head, imports) in IGNORED_BASES: + continue + try: + bases.append(converter.convert(base)[0]) + except ValueError as e: + error(cls.lineno, f"base {ast.unparse(base)}: {e}") + + info = TypeInfo( + clr_name=str(marker["clr_name"]), name=canonical, python_name=cls.name, + relative_path=path.relative_to(root).as_posix(), namespace=str(marker["namespace"]), assembly=str(marker["assembly"]), + kind=kind, repo=marker.get("repo"), description=ast.get_docstring(cls), flags=bool(marker.get("flags", False)), + bases=bases, generic_params=generic_params, notes=notes) + + lines = source.splitlines() + renamed: dict[str, str] = dict(marker.get("renamed_members", {})) + for stmt in cls.body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.target.id != "__bhom__": + name = stmt.target.id + if is_class_var(stmt.annotation, converter): + continue + if name.startswith("_"): + error(stmt.lineno, f"{name}: a field name cannot start with an underscore (Pydantic treats it as private)") + continue + try: + info.properties.append(read_field(stmt, converter)) + except ValueError as e: + error(stmt.lineno, f"{name}: {e}") + elif kind == "enum" and isinstance(stmt, ast.Assign) and isinstance(stmt.targets[0], ast.Name): + py_name = stmt.targets[0].id + comment = lines[stmt.lineno - 2].strip() if stmt.lineno >= 2 and lines[stmt.lineno - 2].strip().startswith("# ") else None + info.members.append(MemberInfo(renamed.get(py_name, py_name), ast.unparse(stmt.value), comment[2:] if comment else None)) + + if kind == "interface": + for name, entry in marker.get("properties", {}).items(): + info.properties.append(PropertyInfo(name=name, clr_type=entry["type"], py_name=name, description=entry.get("description"))) + return info + +def is_field_call(node: ast.expr, converter: Converter) -> bool: + """Returns True when an expression is a call of pydantic `Field`, however it was imported. + + `read_field` uses it to find defaults and descriptions. + """ + return isinstance(node, ast.Call) and name_of(node.func, converter.imports) == "Field" + +def is_ellipsis(node: ast.expr) -> bool: + """Returns True when an expression is the `...` constant, which Pydantic's `Field(...)` uses for a required field. + + Used by `read_field` to tell required fields from fields with a default. + """ + return isinstance(node, ast.Constant) and node.value is Ellipsis + +def read_field(stmt: ast.AnnAssign, converter: Converter) -> PropertyInfo: + """Reads one annotated class attribute into a PropertyInfo: C# type, quantity, display text and dynamic markers from `Annotated`, and default, factory, description and alias from `Field`. + + Called by `_read` for each field of a class. + """ + assert isinstance(stmt.target, ast.Name) + py_name = stmt.target.id + converter.metadata = [] + clr, _ = converter.convert(stmt.annotation) + + prop = PropertyInfo(name=py_name, py_name=py_name, clr_type=clr) + for meta in converter.metadata: + if not isinstance(meta, ast.Call): + continue + kind = converter.original(meta.func) + if kind == "Quantity": + prop.quantity = (str(ast.literal_eval(meta.args[0])), str(ast.literal_eval(meta.args[1]))) + elif kind == "DisplayText": + prop.display_text = str(ast.literal_eval(meta.args[0])) + elif kind == "DynamicProperty": + prop.dynamic = True + + value = stmt.value + if isinstance(value, ast.Call) and is_field_call(value, converter): + # Field(x, ...) and Field(default=x, ...) both give a default; Field(...) with an ellipsis means required + if value.args and not is_ellipsis(value.args[0]): + prop.has_default, prop.default = True, ast.unparse(value.args[0]) + for kw in value.keywords: + if kw.arg == "default" and not is_ellipsis(kw.value): + prop.has_default, prop.default = True, ast.unparse(kw.value) + elif kw.arg == "default_factory": + prop.has_default, prop.default = True, " " + ast.unparse(kw.value) + elif kw.arg == "description": + prop.description = str(ast.literal_eval(kw.value)) + elif kw.arg == "alias": + prop.name = str(ast.literal_eval(kw.value)) + elif value is not None and not is_ellipsis(value): + prop.has_default, prop.default = True, ast.unparse(value) + return prop + +def read_package(package_root: Path, errors: list[ReadError] | None = None) -> Manifest: + """Reads every module under a bhom_schema folder (skipping the hand-written _support package and the generated _version.py) into a manifest keyed by C# name. + + Problems go into `errors`, or are raised together as ReadErrors when no list is given. It is the Python side of every py2cs command: manifest, diff, check, align, plan and verify. + """ + problems: list[ReadError] = [] + resolver = Resolver(package_root) + manifest: Manifest = {} + for path in sorted(package_root.rglob("*.py")): + relative = path.relative_to(package_root).parts + if path.name in ("__init__.py", "_version.py") or relative[0] == "_support": + continue + info = read_file(path, package_root, resolver, problems) + if info is None: + continue + if info.clr_name in manifest: + problems.append(ReadError(info.relative_path, 0, f"{info.clr_name} is already declared in {manifest[info.clr_name].relative_path}")) + continue + manifest[info.clr_name] = info + if errors is not None: + errors.extend(problems) + elif problems: + raise ReadErrors(problems) + return manifest diff --git a/.ci/generation/py2cs/report.py b/.ci/generation/py2cs/report.py new file mode 100644 index 0000000..9c4ddc3 --- /dev/null +++ b/.ci/generation/py2cs/report.py @@ -0,0 +1,55 @@ +"""Markdown description of a plan, for the body of the C# pull request.""" + +from __future__ import annotations + +from py2cs.plan import Plan + +def only(plan: Plan, repo: str) -> Plan: + """The part of a plan that belongs to one repository.""" + return Plan([f for f in plan.files if f.repo.lower() == repo.lower()], [(r, t) for r, t in plan.removed if r.lower() == repo.lower()], plan.errors) + +def render_report(plan: Plan, python_pr: str = "", only_repo: str | None = None) -> str: + """Writes the text of the C# pull request for a plan: the dependency on the Python PR, breaking changes, then per repository each file with its location, changes and notes, removed types and problems. + + With only_repo, only that repository is described. `plan` writes the whole report and one report per repository, and `pr` uses the latter as the pull request body. + """ + if only_repo is not None: + plan = only(plan, only_repo) + + lines: list[str] = [] + if python_pr: + lines.append(f"Depends on {python_pr}") + lines.append("") + lines.append("This is a draft generated from the Python classes in BHoM_PythonSchema. It aligns the C# with the Python change, it is not a") + lines.append("finished implementation: review every file. Existing files are only changed where the Python changed.") + + if plan.labels(): + lines += ["", "## Breaking changes", "", + "Some changes below can break existing C# code or objects saved with an earlier version (labelled `breaking-change`).", + "Add the matching upgrade in Versioning_Toolkit so old files still open, or confirm none is needed. Nothing here is blocked", + "automatically; the reviewer decides.", ""] + lines += [f"- `{f.clr_name}`: {c}" for f in plan.files for c in f.breaking] + lines += [f"- `{t}`: type removed" for _, t in plan.removed] + + for repo in plan.repos(): + lines += ["", f"## {repo}"] + for f in [x for x in plan.files if x.repo == repo]: + lines += ["", f"### `{f.path}` ({'new file' if f.action == 'new' else 'changes to an existing file'})", f"Type: `{f.clr_name}`"] + if f.rule: + lines.append(f"Location: {f.rule}") + if f.changes: + lines += ["", "Changes in Python:"] + [f"- {c}" for c in f.changes] + if f.breaking: + lines += ["", "Breaking changes:"] + [f"- {c}" for c in f.breaking] + if f.notes: + lines += ["", "Notes:"] + [f"- {n}" for n in f.notes] + removed = [t for r, t in plan.removed if r == repo] + if removed: + lines += ["", "Removed in Python (delete or deprecate in C# by hand):"] + [f"- `{t}`" for t in removed] + + unknown = [t for r, t in plan.removed if r == "?"] + if unknown: + lines += ["", "## Removed types with an unknown repository"] + [f"- `{t}`" for t in unknown] + if plan.errors: + lines += ["", "## Problems"] + [f"- {e}" for e in plan.errors] + return "\n".join(lines) + "\n" diff --git a/.ci/generation/py2cs/resolve.py b/.ci/generation/py2cs/resolve.py new file mode 100644 index 0000000..87c15b6 --- /dev/null +++ b/.ci/generation/py2cs/resolve.py @@ -0,0 +1,69 @@ +"""Which GitHub repository owns a class. The generated class records it; a repository listing validates it.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from py2cs.model import TypeInfo + +Listing = dict[str, list[str]] # repository (Owner/Name) -> its top level folders + + +class ResolveError(Exception): + pass + + +@dataclass +class Resolution: + repo: str # canonical spelling from the listing when there is one + source: str # marker | scan + + +def normalise(repo: str) -> str: + """Reduces a repository given as a url, with a trailing slash or a .git suffix, to Owner/Name. Used by `resolve_repo` on the repo recorded in a class. + """ + text = repo.strip().rstrip("/") + if "github.com/" in text: + text = text.split("github.com/", 1)[1] + if text.endswith(".git"): + text = text[:-4] + return text + + +def load_listing(path: Path) -> Listing: + """Reads a repository listing json (repository to top-level folders) from a file. The `plan` and `resolve` commands use it to validate and scan repositories. + """ + return {k: list(v) for k, v in json.loads(path.read_text(encoding="utf-8")).items()} + + +def build_listing(clones: dict[str, Path]) -> Listing: + """Records the top level folders of local clones; used to create the offline test listing.""" + return {repo: sorted(p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")) for repo, root in sorted(clones.items())} + + +def resolve_repo(info: TypeInfo, listing: Listing | None = None) -> Resolution: + """Returns the repository that owns a class: the one recorded in the class (checked against the listing when there is one), otherwise the only listed repository with a folder named after the assembly. + + Raises ResolveError when the repository is unknown or ambiguous. `plan`, `repos` and `resolve` use it to decide where the C# of each class goes. + """ + by_lower = {k.lower(): k for k in (listing or {})} + + if info.repo: + wanted = normalise(info.repo) + if listing is None: + return Resolution(wanted, "marker") + canonical = by_lower.get(wanted.lower()) + if canonical is None: + raise ResolveError(f"{info.clr_name}: repository {wanted} is not in the repository listing (is the GitHub App installed on it?)") + return Resolution(canonical, "marker") + + if listing is None: + raise ResolveError(f"{info.clr_name}: no repo recorded and no repository listing to scan") + matches = [repo for repo, folders in listing.items() if info.assembly.lower() in (f.lower() for f in folders)] + if len(matches) == 1: + return Resolution(matches[0], "scan") + if not matches: + raise ResolveError(f"{info.clr_name}: no repository has a folder named {info.assembly}") + raise ResolveError(f"{info.clr_name}: assembly folder {info.assembly} exists in several repositories: {', '.join(sorted(matches))}") diff --git a/.ci/generation/py2cs/status.py b/.ci/generation/py2cs/status.py new file mode 100644 index 0000000..ebb6ff1 --- /dev/null +++ b/.ci/generation/py2cs/status.py @@ -0,0 +1,61 @@ +"""A commit status on the Python pull request that says whether the linked C# pull request is merged. + +The C# is the source of truth, so a Python PR is only ready once its C# PR is merged. The status makes that visible on the PR, and +can be made a required check in the branch protection of develop. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from py2cs.github import GitHubClient, PullRequest +from py2cs.pr import branch_name + +CONTEXT = "linked C# pull request" + +@dataclass +class Status: + state: str # pending | success | failure + description: str + url: str = "" + +def short(repo: str, pull: PullRequest) -> str: + """Returns a linked pull request as Owner/Name#number for the status text. Used by `linked_status`.""" + return f"{repo}#{pull.number}" + +def linked_status(client: GitHubClient, repos: list[str], python_pr: int) -> Status | None: + """What the linked C# pull requests of one Python pull request say, or None when it has none. + + Merged everywhere is success; any that was closed without merging is a failure (the Python must then change or be closed); + otherwise it is still pending. + """ + found: list[tuple[str, PullRequest]] = [] + for repo in repos: + pull = client.find_linked_pull_request(repo, branch_name(python_pr)) + if pull is not None: + found.append((repo, pull)) + + if not found: + return None + + url = found[0][1].url + abandoned = [short(r, p) for r, p in found if p.state == "closed" and not p.merged] + if abandoned: + return Status("failure", f"{', '.join(abandoned)} was closed without merging", url) + + waiting = [short(r, p) for r, p in found if not p.merged] + if waiting: + return Status("pending", f"Waiting for {', '.join(waiting)} to be merged", url) + + return Status("success", f"Merged: {', '.join(short(r, p) for r, p in found)}", url) + +def update_status(csharp_client: GitHubClient, python_client: GitHubClient, repos: list[str], python_repo: str, python_pr: int, sha: str, + dry_run: bool = False, no_change: bool = False) -> Status | None: + """Writes the status on the head commit of the Python PR. Nothing is written when there is no linked pull request. + + With no_change (the Python PR needs no C# change) the status is success, so a required check does not wait for ever. + """ + status = Status("success", "No C# change is needed") if no_change else linked_status(csharp_client, repos, python_pr) + if status is not None and not dry_run: + python_client.set_commit_status(python_repo, sha, status.state, CONTEXT, status.description, status.url) + return status diff --git a/.ci/generation/warnings-baseline.txt b/.ci/generation/warnings-baseline.txt new file mode 100644 index 0000000..c3fecf9 --- /dev/null +++ b/.ci/generation/warnings-baseline.txt @@ -0,0 +1,4 @@ +# Known warning kinds of the generator. A kind that is not listed fails the run. +# Update with --write-baseline after reviewing a new kind. +default instance of cannot be created in Python, field is required +default value is not a member of enum , field is required diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/CommandLineAndBaselineTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/CommandLineAndBaselineTests.cs new file mode 100644 index 0000000..928c879 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/CommandLineAndBaselineTests.cs @@ -0,0 +1,138 @@ +using PythonSchemaGeneration.Reporting; + +namespace PythonSchemaGeneration.Tests +{ + //The command line and the warning baseline: what lets automation notice a problem instead of ignoring it + public class CommandLineAndBaselineTests + { + /***************************************************/ + /**** Command line ****/ + /***************************************************/ + + [Test] + [Description("Checks that CommandLine.Parse reads every supported option into generation options and the baseline paths. Protects the command line that " + + "the generation workflow calls.")] + public void KnownOptionsAreRead() + { + CommandLine command = CommandLine.Parse(new[] + { + "--output", "out", "--assemblies", "dlls", "--org", "BHoM, Other", "--repos", "BHoM/A,BHoM/B", "--types", "X,Y", + "--manifest", "m.json", "--report", "r.txt", "--baseline-warnings", "base.txt", "--write-baseline", "new.txt", + }); + + Assert.That(command.Error, Is.Null); + Assert.That(command.Options.OutputFolder, Is.EqualTo("out")); + Assert.That(command.Options.AssemblyFolder, Is.EqualTo("dlls")); + Assert.That(command.Options.Organisations, Is.EqualTo(new[] { "BHoM", "Other" })); + Assert.That(command.Options.Repos, Is.EqualTo(new[] { "BHoM/A", "BHoM/B" })); + Assert.That(command.Options.TypeFilter, Is.EqualTo(new[] { "X", "Y" })); + Assert.That(command.Options.ManifestPath, Is.EqualTo("m.json")); + Assert.That(command.Options.ReportPath, Is.EqualTo("r.txt")); + Assert.That(command.BaselinePath, Is.EqualTo("base.txt")); + Assert.That(command.WriteBaselinePath, Is.EqualTo("new.txt")); + } + + /***************************************************/ + + [TestCase(new[] { "--output", "o", "--repo", "BHoM/A" }, "Unknown option --repo")] + [TestCase(new[] { "--output", "o", "--types" }, "--types needs a value")] + [TestCase(new[] { "--output", "--types", "A" }, "--output needs a value")] + [TestCase(new[] { "output", "o" }, "Unexpected argument 'output'")] + [TestCase(new string[0], "Missing --output")] + [Description("Checks that unknown options, missing values, stray arguments and a missing output are reported as errors with no options. Protects the " + + "generator command line so mistakes fail the run.")] + public void MistakesAreErrorsNotIgnored(string[] args, string expected) + { + CommandLine command = CommandLine.Parse(args); + Assert.That(command.Error, Does.Contain(expected)); + Assert.That(command.Options, Is.Null); + } + + /***************************************************/ + /**** Warning baseline ****/ + /***************************************************/ + + [TestCase("[BH.oM.X.Y.Level] default value 0 is not a member of enum BH.oM.X.Sparse, field is required", + "default value is not a member of enum , field is required")] + [TestCase("[BH.oM.X.Y] type System.Collections.Generic.IComparable has no Python equivalent and is mapped to Any", + "type has no Python equivalent and is mapped to Any")] + [TestCase("[A.B] base type System.Attribute is not an oM type and was dropped", "base type is not an oM type and was dropped")] + [TestCase("assembly has no github.com URL in its Description, repo is unknown", "assembly has no URL in its Description, repo is unknown")] + [Description("Checks that WarningBaseline.Kind strips type names and numbers from a warning to give its kind. Protects the warning baseline comparison.")] + public void TheKindOfAWarningHasNoNamesOrNumbers(string warning, string kind) + { + Assert.That(WarningBaseline.Kind(warning), Is.EqualTo(kind)); + } + + /***************************************************/ + + [Test] + [Description("Checks that the same problem in different types counts as one warning kind. Protects the warning baseline from growing for repeated problems.")] + public void TheSameProblemInAnotherTypeIsTheSameKind() + { + string[] warnings = + { + "[BH.oM.A.One.Level] default value 0 is not a member of enum BH.oM.A.Sparse, field is required", + "[BH.oM.B.Two.Size] default value 7 is not a member of enum BH.oM.B.Other, field is required", + }; + Assert.That(WarningBaseline.Kinds(warnings), Has.Count.EqualTo(1)); + } + + /***************************************************/ + + [Test] + [Description("Checks that Compare returns kinds missing from the baseline as new and baseline kinds no longer seen as gone. Protects the rule that a new " + + "warning kind fails the run.")] + public void ANewKindFailsAndAGoneKindIsReported() + { + string[] baseline = { "known kind", "kind that no longer occurs" }; + (List newKinds, List goneKinds) = WarningBaseline.Compare(new[] { "[X.Y] known kind", "[X.Y] something new at 3" }, baseline); + + Assert.That(newKinds, Is.EqualTo(new[] { "something new at " })); + Assert.That(goneKinds, Is.EqualTo(new[] { "kind that no longer occurs" })); + Assert.That(WarningBaseline.Compare(new[] { "known kind" }, new[] { "known kind" }).New, Is.Empty); + } + + /***************************************************/ + + [Test] + [Description("Checks that a baseline file is written with a comment header, sorted and de-duplicated, and read back ignoring comments. Protects the " + + "committed baseline file format.")] + public void TheBaselineFileRoundTripsAndIgnoresComments() + { + string path = Path.Combine(Path.GetTempPath(), "pysg-baseline-" + Guid.NewGuid().ToString("N") + ".txt"); + try + { + WarningBaseline.Write(path, new[] { "[A.B] default value 1 is wrong", "[C.D] default value 2 is wrong", "[E.F] another problem" }); + Assert.That(File.ReadAllText(path), Does.StartWith("# ")); + Assert.That(WarningBaseline.Read(path), Is.EqualTo(new[] { "another problem", "default value is wrong" })); + } + finally + { + File.Delete(path); + } + } + + /***************************************************/ + + [Test] + [Description("Checks that a generator run on the fixtures has no warning kinds outside the committed baseline and no failed types. Protects the committed " + + "baseline against drifting from the generator.")] + public void TheFixtureWarningsAreInTheCommittedBaseline() + { + string baseline = Path.Combine(TestPaths.Root, ".ci", "generation", "warnings-baseline.txt"); + Assert.That(File.Exists(baseline), Is.True, "run the generator with --write-baseline to create it"); + + GenerationResult result = Generator.Run(new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha, FixtureOutput.Beta, typeof(BH.oM.Base.BHoMObject).Assembly }, + Repos = new List { "BHoM/CSharpSchema_Test" }, + }); + + Assert.That(WarningBaseline.Compare(result.Reporter.Warnings, WarningBaseline.Read(baseline)).New, Is.Empty); + Assert.That(result.FailedTypes, Is.Empty); + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/DocumentationTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/DocumentationTests.cs new file mode 100644 index 0000000..6e3aea9 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/DocumentationTests.cs @@ -0,0 +1,68 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace PythonSchemaGeneration.Tests +{ + public class DocumentationTests + { + /***************************************************/ + + private static readonly string[] m_Skipped = { "obj", "bin", ".vs", "_output", "TestResults", "golden" }; + + /***************************************************/ + + [Test] + [Description("Fails with the list of C# methods and constructors that have no Description attribute, so a new method cannot be added without saying what it does.")] + public void EveryMethodHasADescription() + { + List missing = new List(); + string root = Path.Combine(TestPaths.Root, ".ci"); + foreach (string file in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories).OrderBy(f => f, StringComparer.Ordinal)) + { + string relative = Path.GetRelativePath(TestPaths.Root, file); + if (relative.Split(Path.DirectorySeparatorChar).Any(part => m_Skipped.Contains(part))) + continue; + + SyntaxNode tree = CSharpSyntaxTree.ParseText(File.ReadAllText(file)).GetRoot(); + foreach (BaseMethodDeclarationSyntax method in tree.DescendantNodes().OfType()) + { + bool described = method.AttributeLists.SelectMany(l => l.Attributes).Any(a => a.Name.ToString().Split('.').Last() is "Description" or "DescriptionAttribute"); + if (!described) + { + int line = method.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + missing.Add($"{relative.Replace('\\', '/')}:{line} {Name(method)}"); + } + } + } + + Assert.That(missing, Is.Empty, $"{missing.Count} methods have no Description attribute:\n" + string.Join("\n", missing.Take(60))); + } + + /***************************************************/ + + [Test] + [Description("Guards the check above against silently looking at nothing, for example after a folder is renamed.")] + public void TheSearchCoversTheGeneratorThePatcherAndTheTests() + { + string root = Path.Combine(TestPaths.Root, ".ci"); + foreach (string name in new[] { "Generator.cs", "FilePatcher.cs", "PatcherTests.cs" }) + Assert.That(Directory.GetFiles(root, name, SearchOption.AllDirectories), Is.Not.Empty, name); + } + + /***************************************************/ + + [Description("The name of a method or constructor, for the list of missing descriptions.")] + private static string Name(BaseMethodDeclarationSyntax method) + { + return method switch + { + MethodDeclarationSyntax m => m.Identifier.ValueText, + ConstructorDeclarationSyntax c => c.Identifier.ValueText + " (constructor)", + _ => method.Kind().ToString(), + }; + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/FixtureExtractionTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/FixtureExtractionTests.cs new file mode 100644 index 0000000..d73a283 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/FixtureExtractionTests.cs @@ -0,0 +1,386 @@ +using BH.oM.FixtureAlpha; +using PythonSchemaGeneration.Emission; +using PythonSchemaGeneration.Extraction; +using PythonSchemaGeneration.Mapping; +using PythonSchemaGeneration.Model; + +namespace PythonSchemaGeneration.Tests +{ + public class FixtureExtractionTests + { + [Description("Returns the generated type for a fixture System.Type from the shared fixture run. The tests in this class use it to inspect one type's " + + "Python text, bases and properties.")] + private static RenderedType R(Type type) { return FixtureOutput.Rendered(type.FullName); } + + [Description("Joins the simple names of the given types with commas so a type's bases can be compared as one string. Used by the base-type tests.")] + private static string Names(IEnumerable types) { return string.Join(",", types.Select(t => t.Name)); } + + /***************************************************/ + /**** Selection ****/ + /***************************************************/ + + [Test] + [Description("Checks that TypeSelector selects oM classes, enums and generic type definitions but not constructed generics or non-oM types. Protects the " + + "generator's type selection stage.")] + public void SelectorKeepsOmTypesAndDropsOthers() + { + Assert.That(TypeSelector.IsSelected(typeof(PointLike)), Is.True); + Assert.That(TypeSelector.IsSelected(typeof(SupportKind)), Is.True); + Assert.That(TypeSelector.IsSelected(typeof(Wrapper<>)), Is.True); + Assert.That(TypeSelector.IsSelected(typeof(Wrapper)), Is.False, "constructed generics are not selected, only the definition"); + Assert.That(TypeSelector.IsSelected(typeof(string)), Is.False); + Assert.That(TypeSelector.IsSelected(typeof(FixtureOutput)), Is.False); + } + + [Test] + [Description("Checks that a type is marked as an interface by reflection and not by an I prefix in its name. Protects the TypeKind that the emitter uses " + + "to write interface classes.")] + public void InterfaceIsDetectedByReflectionNotByName() + { + Assert.That(R(typeof(ThingIdentity)).Model.Kind, Is.EqualTo(TypeKind.Interface)); + Assert.That(R(typeof(ProjectLike)).Model.Kind, Is.EqualTo(TypeKind.Class)); + } + + /***************************************************/ + /**** Bases ****/ + /***************************************************/ + + [Test] + [Description("Checks that interfaces already implied by another listed base are removed from a type's bases. Protects the base list that extraction hands " + + "to the emitter.")] + public void ImpliedInterfacesAreDropped() + { + Assert.That(Names(R(typeof(ICurveLike)).Model.Bases), Is.EqualTo("IElement1DLike,IGeometryLike")); + Assert.That(Names(R(typeof(LineLike)).Model.Bases), Is.EqualTo("ICurveLike,ILabelled")); + Assert.That(Names(R(typeof(PointLike)).Model.Bases), Is.EqualTo("IGeometryLike")); + } + + [Test] + [Description("Checks that a class's own base class is listed first, followed by its interfaces. Protects the Python base order that the generator writes.")] + public void ClassBaseComesFirstThenInterfaces() + { + Assert.That(Names(R(typeof(ImmutableThing)).Model.Bases), Is.EqualTo("BHoMObject,IImmutable")); + } + + /***************************************************/ + /**** Provenance ****/ + /***************************************************/ + + [Test] + [Description("Checks that the repository of a type is read from the GitHub URL in its assembly description and written into the generated class marker. " + + "Protects the provenance that py2cs later uses to find the owning C# repository.")] + public void RepoComesFromTheAssemblyDescription() + { + Assert.That(R(typeof(ProjectLike)).Model.Repo, Is.EqualTo("BHoM/CSharpSchema_Test")); + Assert.That(FixtureOutput.Rendered(typeof(BH.oM.FixtureBeta.PointLike).FullName).Model.Repo, Is.EqualTo("BHoM/CSharpSchema_Test")); + Assert.That(R(typeof(ProjectLike)).Text, Does.Contain("'repo': 'BHoM/CSharpSchema_Test'")); + Assert.That(R(typeof(ProjectLike)).Text, Does.Contain("'namespace': 'BH.oM.FixtureAlpha'")); + Assert.That(R(typeof(ProjectLike)).Text, Does.Contain("'assembly': 'FixtureAlpha_oM'")); + } + + [Test] + [Description("Checks that every generated class carries the clr_name, namespace, assembly, repo and kind marker entries. Protects the marker that py2cs, " + + "alignment and verification rely on.")] + public void EveryGeneratedClassCarriesTheFullMarker() + { + foreach (RenderedType type in FixtureOutput.Result.Types) + { + Assert.That(type.Text, Does.Contain("'clr_name': '" + type.Model.ClrName + "'"), type.Model.ClrName); + Assert.That(type.Text, Does.Contain("'namespace':"), type.Model.ClrName); + Assert.That(type.Text, Does.Contain("'assembly':"), type.Model.ClrName); + Assert.That(type.Text, Does.Contain("'repo': '"), type.Model.ClrName); + Assert.That(type.Text, Does.Contain("'kind':"), type.Model.ClrName); + } + } + + /***************************************************/ + /**** Defaults and required fields ****/ + /***************************************************/ + + [Test] + [Description("Checks that string property defaults and the property order are read from a default instance and written to Python. Protects the default " + + "extraction stage.")] + public void StringDefaultsAreRead() + { + string text = R(typeof(ProjectLike)).Text; + Assert.That(text, Does.Contain("ProjectReference: str = ''")); + Assert.That(text, Does.Contain("AdditionalReferences: str = ''")); + Assert.That(R(typeof(ProjectLike)).Properties.Select(p => p.Name), Is.EqualTo(new[] { "ProjectReference", "ProjectName", "ProjectLocation", "AdditionalReferences" })); + } + + [Test] + [Description("Checks that properties of an immutable type are written as required fields with no default. Protects how the Python model reflects " + + "constructor-initialised C# types.")] + public void ImmutableTypeFieldsAreRequired() + { + RenderedType thing = R(typeof(ImmutableThing)); + Assert.That(thing.Properties.Single(p => p.Name == "Id").HasDefault, Is.False); + Assert.That(thing.Properties.Single(p => p.Name == "Length").HasDefault, Is.False); + Assert.That(thing.Text, Does.Contain(" Id: str\n")); + Assert.That(thing.Text, Does.Contain(" Length: float\n")); + } + + [Test] + [Description("Checks that object defaults become default factories and null defaults become None on optional types. Protects default value emission for " + + "reference-typed properties.")] + public void ObjectAndNullDefaults() + { + Assert.That(R(typeof(LineLike)).Text, Does.Contain("Start: PointLike = Field(default_factory=lambda: PointLike())")); + Assert.That(R(typeof(LineLike)).Text, Does.Contain("Label: str | None = None")); + Assert.That(R(typeof(Beam)).Text, Does.Contain("Start: PointLike | None = None")); + } + + [Test] + [Description("Checks that dates, GUIDs, decimals, colours, time spans, quoted strings and extreme floats are written as valid Python defaults, and that " + + "inherited properties are not repeated. Protects PyLiteral and DefaultFormatter output.")] + public void VolatileAndScalarDefaults() + { + string text = R(typeof(Scalars)).Text; + Assert.That(text, Does.Contain("Created: datetime = Field(default_factory=datetime.now)")); + Assert.That(text, Does.Contain("Fixed: datetime = datetime(2020, 1, 2, 3, 4, 5, 0)")); + Assert.That(text, Does.Contain("Key: UUID = UUID(int=0)")); + Assert.That(text, Does.Contain("Cost: Decimal = Decimal('1.5')")); + Assert.That(text, Does.Contain("Colour: Color = Field(default_factory=Color)")); + Assert.That(text, Does.Contain("Duration: timedelta = timedelta(seconds=90.0)")); + Assert.That(text, Does.Contain("Quote: str = ")); + Assert.That(text, Does.Contain("Big: float = 1.7976931348623157e+308")); + Assert.That(text, Does.Not.Contain("BHoM_Guid"), "inherited properties are not repeated"); + } + + [Test] + [Description("Checks that enum defaults are written as members of the generated enum class with a top-level import. Protects default emission and the " + + "import ordering for enum properties.")] + public void EnumDefaultsUseTheEnumClass() + { + string text = R(typeof(Beam)).Text; + Assert.That(text, Does.Contain("Support: SupportKind = SupportKind.Pinned")); + Assert.That(text, Does.Contain("Flags: Options = Options.A")); + Assert.That(text, Does.Contain("from bhom_schema.FixtureAlpha_oM.SupportKind import SupportKind\n"), "enum defaults need a top level import"); + } + + /***************************************************/ + /**** Type mapping ****/ + /***************************************************/ + + [Test] + [Description("Checks that CLR types with no exact Python equivalent (long, float, arrays, enumerables) are kept in a ClrType annotation, while tuples and " + + "nullables map directly. Protects TypeMapper, which lets the Python round trip back to the same C# types.")] + public void NonDefaultClrTypesAreKept() + { + string beam = R(typeof(Beam)).Text; + Assert.That(beam, Does.Contain("Count: Annotated[int, ClrType('long')] = 3")); + Assert.That(beam, Does.Contain("Small: Annotated[float, ClrType('float')] = 0.5")); + + string collections = R(typeof(Collections)).Text; + Assert.That(collections, Does.Contain("Values: Annotated[list[float], ClrType('double[]')]")); + Assert.That(collections, Does.Contain("Words: Annotated[list[str], ClrType('System.Collections.Generic.IEnumerable')]")); + Assert.That(collections, Does.Contain("Pair: tuple[int, str] | None = None")); + Assert.That(collections, Does.Contain("OptionalCount: int | None = None")); + Assert.That(collections, Does.Contain("Anything: Any | None = None").Or.Contain("Anything: Any = None")); + } + + [Test] + [Description("Checks that sets and dictionaries of hashable items map to set and dict, while unhashable items fall back to lists with the original type " + + "kept. Protects the TypeMapper hashability rule.")] + public void SetsAndDictionariesFallBackToListsForUnhashableItems() + { + string text = R(typeof(Collections)).Text; + Assert.That(text, Does.Contain("Names: set[str] = Field(default_factory=set)")); + Assert.That(text, Does.Contain("Lookup: dict[str, float] = Field(default_factory=dict)")); + Assert.That(text, Does.Contain("Points: Annotated[list[PointLike], ClrType('System.Collections.Generic.HashSet')]")); + Assert.That(text, Does.Contain("ByPoint: Annotated[list[tuple[PointLike, str]], ClrType('System.Collections.Generic.Dictionary')]")); + } + + [Test] + [Description("Checks that TypeMapper.IsHashable accepts strings, enums, GUIDs and tuples of hashable items and rejects oM objects. Protects the rule that " + + "decides between set or dict and a list.")] + public void HashableRule() + { + Assert.That(TypeMapper.IsHashable(typeof(string)), Is.True); + Assert.That(TypeMapper.IsHashable(typeof(SupportKind)), Is.True); + Assert.That(TypeMapper.IsHashable(typeof(Guid)), Is.True); + Assert.That(TypeMapper.IsHashable(typeof(Tuple)), Is.True); + Assert.That(TypeMapper.IsHashable(typeof(PointLike)), Is.False); + Assert.That(TypeMapper.IsHashable(typeof(Tuple)), Is.False); + } + + [Test] + [Description("Checks that delegates and System.Type properties are mapped to Any with the original CLR type kept. Protects TypeMapper's fallback for types " + + "with no Python equivalent.")] + public void UnsupportedTypesBecomeAnyWithTheOriginalTypeKept() + { + string text = R(typeof(Scalars)).Text; + Assert.That(text, Does.Contain("Callback: Annotated[SkipJsonSchema[Callable[..., Any]] | None, ClrType('System.Func')] = None")); + Assert.That(text, Does.Contain("TypeRef: Annotated[Any, ClrType('System.Type')]").Or.Contain("TypeRef: Annotated[Any | None, ClrType('System.Type')]")); + } + + /***************************************************/ + /**** Generics, enums, names ****/ + /***************************************************/ + + [Test] + [Description("Checks that a generic oM class is named with its arity, declares its type parameter with its bound and keeps the original CLR name in the " + + "marker. Protects naming and emission of generics.")] + public void GenericTypeUsesArityNameAndBound() + { + RenderedType wrapper = R(typeof(Wrapper<>)); + Assert.That(wrapper.Ref.Name, Is.EqualTo("Wrapper_1")); + Assert.That(wrapper.Text, Does.Contain("class Wrapper_1[T: Polymorphic[IBHoMObject]](BHoMObject):")); + Assert.That(wrapper.Text, Does.Contain("'clr_name': 'BH.oM.FixtureAlpha.Wrapper`1'")); + Assert.That(wrapper.Text, Does.Contain("Items: list[T] = Field(default_factory=lambda: [])")); + } + + [Test] + [Description("Checks that a generic class referring to itself in a default value uses a lambda default factory. Protects emission of self-referencing " + + "generic defaults.")] + public void GenericClassNamingItselfInADefaultUsesALambda() + { + RenderedType tree = R(typeof(TreeLike<>)); + Assert.That(tree.Text, Does.Contain("Children: dict[str, TreeLike_1[T]] = Field(default_factory=lambda: {})")); + } + + [Test] + [Description("Checks that an enum default that is not a member makes the field required and records a warning. Protects defaults handling and the warning " + + "that the baseline compares.")] + public void EnumDefaultThatIsNotAMemberMakesTheFieldRequired() + { + Assert.That(R(typeof(SparseHolder)).Text, Does.Contain(" Level: Sparse\n")); + Assert.That(R(typeof(SparseParent)).Text, Does.Contain(" Holder: SparseHolder\n")); + Assert.That(FixtureOutput.Result.Reporter.Warnings, Has.Some.Contain("is not a member of enum")); + } + + [Test] + [Description("Checks that a two-dimensional array default is written as rows in a default factory, with its dictionary type kept. Protects array default emission.")] + public void TwoDimensionalArrayDefaultIsWrittenAsRows() + { + string text = R(typeof(GridHolder)).Text; + Assert.That(text, Does.Contain("Field(default_factory=lambda: [[0.0, 0.0], [0.0, 0.0]])")); + Assert.That(text, Does.Contain("dict[str, float]")); + } + + [Test] + [Description("Checks that a generic constraint that refers to another type parameter is written with Any. Protects the Python generic header for " + + "interfaces with mutually constrained parameters.")] + public void ConstraintThatNamesATypeParameterGetsAny() + { + string text = R(typeof(IPanelLike<,>)).Text; + Assert.That(text, Does.Contain("class IPanelLike_2[TEdge, TOpening: IOpeningLike_1[Any]]")); + } + + [Test] + [Description("Checks that fields typed with an interface are written as Polymorphic[...] (also inside lists and optionals), while base classes stay plain, so JSON " + + "for those fields carries _t. Protects the use of Polymorphic by the type mapper, and the base class list.")] + public void InterfaceTypedFieldsArePolymorphicButBasesAreNot() + { + string holder = R(typeof(GeometryHolder)).Text; + Assert.That(holder, Does.Contain("Geometry: Polymorphic[IGeometryLike] | None = None")); + Assert.That(holder, Does.Contain("Curves: list[Polymorphic[ICurveLike]] = Field(default_factory=list)")); + Assert.That(holder, Does.Contain("from bhom_schema._support import Polymorphic")); + + string line = R(typeof(LineLike)).Text; + Assert.That(line, Does.Contain("class LineLike(SchemaModel, ICurveLike, ILabelled):"), "base classes are not wrapped"); + Assert.That(line, Does.Not.Contain("Polymorphic")); + } + + [Test] + [Description("Checks that an interface lists, in its marker, the generated classes that implement it as C# name to module path, that an interface with none has an " + + "empty list, and that a generic interface has no list. Protects what the Polymorphic type accepts when it reads JSON.")] + public void InterfacesListTheirImplementers() + { + string curve = R(typeof(ICurveLike)).Text; + Assert.That(curve, Does.Contain("'implementers': {\n 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike',\n },")); + + Assert.That(R(typeof(ILabelled)).Text, Does.Contain("'implementers':")); + Assert.That(R(typeof(IOpeningLike<>)).Text, Does.Not.Contain("implementers"), "an interface with type parameters cannot be told apart by _t"); + Assert.That(R(typeof(LineLike)).Text, Does.Not.Contain("implementers"), "a class lists nothing"); + } + + [Test] + [Description("Checks that generated enums derive from BHoMEnum, and flags enums from BHoMFlags, so they are written by name. Protects the enum base classes " + + "that the enum JSON of the support package relies on.")] + public void EnumsUseTheBHoMEnumBases() + { + Assert.That(R(typeof(SupportKind)).Text, Does.Contain("from bhom_schema._support import BHoMEnum")); + Assert.That(R(typeof(Options)).Text, Does.Contain("from bhom_schema._support import BHoMFlags")); + } + + [Test] + [Description("Checks that an enum member named None is renamed to None_ and the original name is recorded in the marker, and that flag enums use the " + + "BHoMFlags base. Protects enum emission and the reverse mapping used by py2cs.")] + public void EnumMemberNamedNoneIsRenamedAndRecorded() + { + string text = R(typeof(SupportKind)).Text; + Assert.That(text, Does.Contain("class SupportKind(BHoMEnum):")); + Assert.That(text, Does.Contain(" None_ = 0")); + Assert.That(text, Does.Contain("'renamed_members': {'None_': 'None'}")); + Assert.That(text, Does.Contain("# Fully fixed.")); + Assert.That(R(typeof(Options)).Text, Does.Contain("class Options(BHoMFlags):")); + } + + [Test] + [Description("Checks that two types with the same name from different assemblies are imported under distinct aliases in one generated file. Protects " + + "NameTracker and import generation.")] + public void SameNamedTypesGetAliasesInOneFile() + { + string text = FixtureOutput.Rendered("BH.oM.FixtureBeta.Elements.UsesBoth").Text; + Assert.That(text, Does.Contain("import PointLike as FixtureAlpha_oM_PointLike").Or.Contain("PointLike as FixtureBeta_oM_PointLike")); + Assert.That(text, Does.Contain("FromAlpha:")); + Assert.That(text, Does.Contain("Local:")); + } + + /***************************************************/ + /**** Metadata ****/ + /***************************************************/ + + [Test] + [Description("Checks that descriptions, quantity category and unit, and display text from C# attributes are carried into the model and the generated " + + "Python. Protects MetadataReader output.")] + public void DescriptionsQuantityAndDisplayTextAreCarried() + { + RenderedType beam = R(typeof(Beam)); + RenderedProperty span = beam.Properties.Single(p => p.Name == "Span"); + Assert.That(span.Quantity.Category, Is.EqualTo("Length")); + Assert.That(span.Quantity.Unit, Is.EqualTo("m")); + Assert.That(span.DisplayText, Is.EqualTo("Span length")); + Assert.That(beam.Text, Does.Contain("Span: Annotated[float, Quantity('Length', 'm'), DisplayText('Span length')] = Field(default=1.5, description='Distance between the supports.')")); + Assert.That(R(typeof(LineLike)).Text, Does.Contain("\"\"\"A straight segment between two points.\"\"\"")); + } + + [Test] + [Description("Checks that a type with no description is written without a docstring. Protects emission of class documentation.")] + public void TypesWithoutDescriptionGetNoDocstring() + { + Assert.That(R(typeof(ProjectLike)).Text, Does.Not.Contain("\"\"\"")); + } + + /***************************************************/ + /**** Paths and cycles ****/ + /***************************************************/ + + [Test] + [Description("Checks that module paths and written files follow the namespace of each type, relative to its assembly folder. Protects TypeRegistry and " + + "OutputWriter placement.")] + public void PathsFollowTheNamespaceNotTheSourceFolder() + { + Assert.That(R(typeof(ProjectLike)).Ref.RelativePath, Is.EqualTo("FixtureAlpha_oM/ProjectLike")); + Assert.That(R(typeof(LineLike)).Ref.RelativePath, Is.EqualTo("FixtureAlpha_oM/LineLike")); + Assert.That(FixtureOutput.Rendered("BH.oM.FixtureBeta.Elements.UsesBoth").Ref.RelativePath, Is.EqualTo("FixtureBeta_oM/Elements/UsesBoth")); + Assert.That(File.Exists(Path.Combine(FixtureOutput.Package, "FixtureAlpha_oM", "ProjectLike.py"))); + Assert.That(File.Exists(Path.Combine(FixtureOutput.Package, "FixtureBeta_oM", "Elements", "UsesBoth.py"))); + } + + [Test] + [Description("Checks that mutually referencing types import each other after the class and call model_rebuild, while other types do not, and that the " + + "cycle count is reported. Protects CycleDetector and the emitted import placement.")] + public void CyclicReferencesUseBottomImports() + { + string a = R(typeof(CycleA)).Text; + string b = R(typeof(CycleB)).Text; + Assert.That(a, Does.Contain("CycleA.model_rebuild()")); + Assert.That(b, Does.Contain("CycleB.model_rebuild()")); + Assert.That(a.IndexOf("from bhom_schema.FixtureAlpha_oM.CycleB import CycleB"), Is.GreaterThan(a.IndexOf("class CycleA"))); + Assert.That(R(typeof(PointLike)).Text, Does.Not.Contain("model_rebuild"), "types outside a cycle do not need a rebuild"); + Assert.That(FixtureOutput.Result.CyclicTypes, Is.EqualTo(2)); + } + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs new file mode 100644 index 0000000..0246549 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs @@ -0,0 +1,165 @@ +using System.Text; +using PythonSchemaGeneration; +using PythonSchemaGeneration.Emission; + +namespace PythonSchemaGeneration.Tests +{ + public static class GoldenAssert + { + [Description("Compares generated text with the golden file of the same name, or rewrites the golden file when UPDATE_GOLDEN is 1, normalising line " + + "endings. Used by the golden tests to pin the exact generator output.")] + public static void Matches(string goldenFolder, string relativePath, string actual) + { + string path = Path.Combine(TestPaths.Golden(goldenFolder), relativePath.Replace('/', Path.DirectorySeparatorChar)); + actual = actual.Replace("\r\n", "\n"); + if (Environment.GetEnvironmentVariable("UPDATE_GOLDEN") == "1") + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, actual, new UTF8Encoding(false)); + return; + } + Assert.That(File.Exists(path), Is.True, $"missing golden file {path} (run with UPDATE_GOLDEN=1 to create it)"); + Assert.That(actual, Is.EqualTo(File.ReadAllText(path).Replace("\r\n", "\n")), $"golden file {relativePath} differs"); + } + } + + public class GoldenTests + { + [Test] + [Description("Checks that the Python generated for every fixture type is identical to its committed golden file. Protects the exact output of the " + + "generator against unintended changes.")] + public void FixtureOutputMatchesGoldenFiles() + { + foreach (RenderedType type in FixtureOutput.Result.Types.Where(t => t.Ref.RelativePath.StartsWith("Fixture"))) + GoldenAssert.Matches("fixtures", type.Ref.RelativePath + ".py", type.Text); + } + + [Test] + [Description("Checks that the manifest written for the fixture run is identical to the committed golden manifest. Protects the language-neutral manifest " + + "that py2cs and alignment read.")] + public void FixtureManifestMatchesGolden() + { + GoldenAssert.Matches("fixtures", "manifest.json", File.ReadAllText(Path.Combine(FixtureOutput.Folder, "manifest.json"))); + } + } + + public class WorkflowTests + { + [Test] + [Description("Checks that two generator runs produce identical files with LF line endings. Protects the weekly generation workflow from committing noise.")] + public void OutputIsDeterministic() + { + string a = Path.Combine(Path.GetTempPath(), "pysg-a-" + Guid.NewGuid().ToString("N")); + string b = Path.Combine(Path.GetTempPath(), "pysg-b-" + Guid.NewGuid().ToString("N")); + try + { + FixtureOutput.Generate(a, false); + FixtureOutput.Generate(b, false); + string[] filesA = Directory.GetFiles(a, "*.py", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(a, f)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + string[] filesB = Directory.GetFiles(b, "*.py", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(b, f)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.That(filesA, Is.EqualTo(filesB)); + foreach (string file in filesA) + Assert.That(File.ReadAllBytes(Path.Combine(a, file)), Is.EqualTo(File.ReadAllBytes(Path.Combine(b, file))), file); + Assert.That(File.ReadAllText(Path.Combine(a, "bhom_schema", "FixtureAlpha_oM", "PointLike.py")), Does.Not.Contain("\r")); + } + finally + { + if (Directory.Exists(a)) Directory.Delete(a, true); + if (Directory.Exists(b)) Directory.Delete(b, true); + } + } + + [Test] + [Description("Checks that a whole-assembly run deletes stale generated files and empty folders but keeps the hand-written support package. Protects " + + "OutputWriter's clean-up in the weekly generation.")] + public void RegenerationRemovesStaleFilesButNeverTheSupportPackage() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-clean-" + Guid.NewGuid().ToString("N")); + try + { + string package = Path.Combine(folder, "bhom_schema"); + FixtureOutput.CopySupport(package); + Directory.CreateDirectory(Path.Combine(package, "FixtureAlpha_oM", "Old", "Deeper")); + File.WriteAllText(Path.Combine(package, "FixtureAlpha_oM", "Stale.py"), "x = 1\n"); + File.WriteAllText(Path.Combine(package, "FixtureAlpha_oM", "Old", "Deeper", "Gone.py"), "x = 1\n"); + File.WriteAllText(Path.Combine(package, "_support", "keep_me.py"), "x = 1\n"); + + //Whole assembly run (no type filter) so the clean-up is active + GenerationResult result = Generator.Run(new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha, typeof(BH.oM.Base.BHoMObject).Assembly }, + OutputFolder = package, + TypeFilter = null, + }); + + Assert.That(File.Exists(Path.Combine(package, "FixtureAlpha_oM", "Stale.py")), Is.False); + Assert.That(Directory.Exists(Path.Combine(package, "FixtureAlpha_oM", "Old")), Is.False, "empty folders are removed"); + Assert.That(File.Exists(Path.Combine(package, "_support", "keep_me.py")), Is.True); + Assert.That(File.Exists(Path.Combine(package, "FixtureAlpha_oM", "PointLike.py")), Is.True); + Assert.That(result.Types.Count, Is.GreaterThan(10)); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + [Test] + [Description("Checks that a run limited by a type filter does not delete existing files. Protects OutputWriter so partial runs cannot remove other " + + "generated modules.")] + public void FilteredRunDoesNotDeleteAnything() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-partial-" + Guid.NewGuid().ToString("N")); + try + { + string package = Path.Combine(folder, "bhom_schema"); + Directory.CreateDirectory(Path.Combine(package, "FixtureAlpha_oM")); + File.WriteAllText(Path.Combine(package, "FixtureAlpha_oM", "Other.py"), "x = 1\n"); + Generator.Run(new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha, typeof(BH.oM.Base.BHoMObject).Assembly }, + OutputFolder = package, + TypeFilter = new List { "BH.oM.FixtureAlpha.PointLike" }, + }); + Assert.That(File.Exists(Path.Combine(package, "FixtureAlpha_oM", "Other.py")), Is.True); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + [Test] + [Description("Checks that an unknown name in the type filter is reported as a warning while the other requested types are still generated. Protects " + + "Generator's handling of the --types option.")] + public void UnknownRequestedTypeIsReportedNotFatal() + { + GenerationResult result = Generator.Run(new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha }, + TypeFilter = new List { "BH.oM.Nope.DoesNotExist", "BH.oM.FixtureAlpha.PointLike" }, + }); + Assert.That(result.Reporter.Warnings.Any(w => w.Contains("DoesNotExist")), Is.True); + Assert.That(result.Types.Any(t => t.Model.ClrName == "BH.oM.FixtureAlpha.PointLike"), Is.True); + } + + [Test] + [Description("Checks that no file of the generator mentions the BHoM_JSONSchema repository. Protects the generator's independence from that repository.")] + public void GeneratorHasNoDependencyOnTheJsonSchemaRepo() + { + string generation = Path.Combine(TestPaths.Root, ".ci", "generation"); + foreach (string file in Directory.GetFiles(generation, "*.cs*", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) && !f.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar))) + { + string text = File.ReadAllText(file); + Assert.That(text, Does.Not.Contain("BHoM_JSONSchema"), file); + } + } + + [Test] + [Description("Checks that no generated type is placed in the hand-written support package. Protects TypeRegistry from overwriting support modules.")] + public void HandWrittenSupportPackageIsNotGenerated() + { + Assert.That(FixtureOutput.Result.Types.Any(t => t.Ref.Module.StartsWith("bhom_schema._support")), Is.False); + } + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PatcherTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PatcherTests.cs new file mode 100644 index 0000000..d9d3deb --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PatcherTests.cs @@ -0,0 +1,362 @@ +using PythonSchemaGeneration.Patcher; + +namespace PythonSchemaGeneration.Tests +{ + //The Roslyn patcher: only the members a change names are touched, everything else stays byte for byte + public class PatcherTests + { + private static readonly string Demo = Lf(""" + using BH.oM.Base; + using System.ComponentModel; + + namespace BH.oM.Demo + { + [Description("A demo.")] + public class Demo : BHoMObject, IThing + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + [Description("First.")] + [DocumentationURL("https://bhom.xyz", DocumentationType.Documentation)] + public virtual double Length { get; set; } = 0; + + // Kept comment + public virtual string Name { get; set; } = null; + + [Length] + public virtual double Width { get; set; } = 1.5; + + /***************************************************/ + } + + public enum Mode + { + Slow, + Fast + } + + public enum Coded + { + A = 1, + B = 2, + } + } + + """); + + /***************************************************/ + + [Test] + [Description("Checks that patching with no changes returns a real BHoM file and a demo file unchanged. Protects the Roslyn patcher's promise to touch only " + + "what a change names.")] + public void NoChangesKeepTheFileByteForByte() + { + string bar = File.ReadAllText(TestPaths.Golden("patcher/Bar.cs")); + Assert.That(Patch(bar, "Bar", "BH.oM.Structure.Elements").Text, Is.EqualTo(bar)); + Assert.That(Patch(Demo, "Demo").Text, Is.EqualTo(Demo)); + } + + /***************************************************/ + + [Test] + [Description("Checks that adding a property to the real Bar class inserts only its attribute and property lines after the named neighbour and adds no " + + "notes. Protects the add-property change that py2cs sends to the patcher.")] + public void AddedPropertyOnTheRealBarChangesOnlyItsLines() + { + string bar = File.ReadAllText(TestPaths.Golden("patcher/Bar.cs")); + string nl = bar.Contains("\r\n") ? "\r\n" : "\n"; + string anchor = " public virtual ISectionProperty SectionProperty { get; set; } = null;" + nl; + Assert.That(bar, Does.Contain(anchor)); + + Change mark = new Change { Kind = "addProperty", Name = "Mark", Type = "string", Initialiser = "\"\"", After = "SectionProperty", Add = { "Description(\"Fabrication mark\")" } }; + PatchResult result = Patch(bar, "Bar", "BH.oM.Structure.Elements", new List { "System.ComponentModel" }, mark); + + string expected = bar.Replace(anchor, anchor + nl + " [Description(\"Fabrication mark\")]" + nl + " public virtual string Mark { get; set; } = \"\";" + nl); + Assert.That(result.Text, Is.EqualTo(expected)); + Assert.That(result.Notes, Is.Empty); + } + + /***************************************************/ + + [Test] + [Description("Checks that removing a property from the real Bar class also removes its attributes and comment but leaves the class attribute and other " + + "members. Protects the remove-property change.")] + public void RemovedPropertyOnTheRealBarTakesItsAttributesAndKeepsTheOthers() + { + string bar = File.ReadAllText(TestPaths.Golden("patcher/Bar.cs")); + string text = Patch(bar, "Bar", "BH.oM.Structure.Elements", null, new Change { Kind = "removeProperty", Name = "OrientationAngle" }).Text; + + Assert.That(text, Does.Not.Contain("OrientationAngle")); + Assert.That(text, Does.Not.Contain("[Angle]")); + Assert.That(text, Does.Not.Contain("Controls the local axis orientation")); + Assert.That(Count(text, "DocumentationURL"), Is.EqualTo(Count(bar, "DocumentationURL") - 1), "the class attribute stays"); + Assert.That(text, Does.Contain("public virtual BarRelease Release { get; set; } = null;")); + } + + /***************************************************/ + + [Test] + [Description("Checks that a property is added after the last member of a real dKoP class without disturbing anything else. Protects patching of files with " + + "a different layout.")] + public void ARealDkopPropertyIsAddedAtTheEnd() + { + string source = File.ReadAllText(TestPaths.Golden("patcher/ProjectIdentification.cs")); + string nl = source.Contains("\r\n") ? "\r\n" : "\n"; + string anchor = " public virtual string AdditionalReferences { get; set; } = \"\";" + nl; + string text = Patch(source, "ProjectIdentification", "BH.oM.dKoP", null, + new Change { Kind = "addProperty", Name = "ProjectNumber", Type = "string", Initialiser = "\"\"", After = "AdditionalReferences" }).Text; + + Assert.That(text, Is.EqualTo(source.Replace(anchor, anchor + nl + " public virtual string ProjectNumber { get; set; } = \"\";" + nl))); + } + + /***************************************************/ + + [Test] + [Description("Checks that a property with an anchor is added after it and one without an anchor is added first, with the requested attributes. Protects " + + "property placement.")] + public void PropertiesAreAddedAfterTheirNeighbourOrFirst() + { + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, + new Change { Kind = "addProperty", Name = "Height", Type = "double", Initialiser = "0.0", After = "Width", Add = { "Description(\"H.\")" } }, + new Change { Kind = "addProperty", Name = "Id", Type = "string" }); + + string expected = Demo + .Replace(" public virtual double Width { get; set; } = 1.5;\n", + " public virtual double Width { get; set; } = 1.5;\n\n [Description(\"H.\")]\n public virtual double Height { get; set; } = 0.0;\n") + .Replace(" [Description(\"First.\")]\n", " public virtual string Id { get; set; }\n\n [Description(\"First.\")]\n"); + Assert.That(result.Text, Is.EqualTo(expected)); + } + + /***************************************************/ + + [Test] + [Description("Checks that removing a property also removes its leading comment and one blank line. Protects the formatting left behind by the " + + "remove-property change.")] + public void RemovedPropertyTakesItsCommentAndOneBlankLine() + { + string text = Patch(Demo, "Demo", "BH.oM.Demo", null, new Change { Kind = "removeProperty", Name = "Name" }).Text; + Assert.That(text, Is.EqualTo(Demo.Replace(" // Kept comment\n public virtual string Name { get; set; } = null;\n\n", ""))); + } + + /***************************************************/ + + [Test] + [Description("Checks that property types and initialisers are changed in place and that equal defaults such as 0 and 0.0 are left alone. Protects the " + + "set-type and set-initialiser changes.")] + public void TypesAndDefaultsChangeInPlace() + { + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, + new Change { Kind = "setPropertyType", Name = "Width", Type = "float" }, + new Change { Kind = "setPropertyInitialiser", Name = "Width" }, + new Change { Kind = "setPropertyInitialiser", Name = "Length", Initialiser = "0.0" }, + new Change { Kind = "setPropertyInitialiser", Name = "Name", Initialiser = "\"x\"" }); + + string expected = Demo + .Replace("public virtual double Width { get; set; } = 1.5;", "public virtual float Width { get; set; }") + .Replace("public virtual string Name { get; set; } = null;", "public virtual string Name { get; set; } = \"x\";"); + Assert.That(result.Text, Is.EqualTo(expected), "0 and 0.0 are the same default, so Length is untouched"); + } + + /***************************************************/ + + [Test] + [Description("Checks that descriptions on properties and the class are replaced, added or removed without touching other attributes. Protects the " + + "set-description change.")] + public void DescriptionsAreReplacedAddedOrRemovedWithoutTouchingOtherAttributes() + { + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, + new Change { Kind = "setDescription", Name = "Length", Description = "\"Changed.\"" }, + new Change { Kind = "setDescription", Name = "Name", Description = "\"N.\"" }, + new Change { Kind = "setDescription" }); + + string expected = Demo + .Replace("[Description(\"First.\")]", "[Description(\"Changed.\")]") + .Replace(" // Kept comment\n", " // Kept comment\n [Description(\"N.\")]\n") + .Replace(" [Description(\"A demo.\")]\n", ""); + Assert.That(result.Text, Is.EqualTo(expected)); + Assert.That(result.Text, Does.Contain("DocumentationURL")); + } + + /***************************************************/ + + [Test] + [Description("Checks that attributes are removed and added by name and that removing an absent attribute produces a note. Protects the set-attributes " + + "change for quantities and display text.")] + public void AttributesAreSwappedByName() + { + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, + new Change { Kind = "setAttributes", Name = "Width", Remove = { "Length" }, Add = { "Area" } }, + new Change { Kind = "setAttributes", Name = "Length", Add = { "DisplayText(\"L\")" } }, + new Change { Kind = "setAttributes", Name = "Name", Remove = { "DisplayText" } }); + + string expected = Demo + .Replace(" [Length]\n", " [Area]\n") + .Replace("DocumentationType.Documentation)]\n", "DocumentationType.Documentation)]\n [DisplayText(\"L\")]\n"); + Assert.That(result.Text, Is.EqualTo(expected)); + Assert.That(result.Notes, Has.One.Contains("DisplayText was not found")); + } + + /***************************************************/ + + [Test] + [Description("Checks that base types are added and removed by name and that a missing base produces a note. Protects the set-bases change.")] + public void BasesAreAddedAndRemovedByName() + { + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, new Change { Kind = "setBases", Remove = { "IThing", "IMissing" }, Add = { "IOther", "BH.oM.Base.BHoMObject" } }); + Assert.That(result.Text, Is.EqualTo(Demo.Replace("public class Demo : BHoMObject, IThing", "public class Demo : BHoMObject, IOther"))); + Assert.That(result.Notes, Has.One.Contains("IMissing")); + } + + /***************************************************/ + + [Test] + [Description("Checks that a member added to an enum without explicit values goes at the end with a note, so existing values do not shift. Protects the " + + "add-enum-member change.")] + public void EnumMembersWithoutValuesOnlyGrowAtTheEnd() + { + PatchResult result = Patch(Demo, "Mode", "BH.oM.Demo", null, new Change { Kind = "addEnumMember", Name = "Medium", Value = "2", After = "Slow" }); + Assert.That(result.Text, Is.EqualTo(Demo.Replace(" Slow,\n Fast\n", " Slow,\n Fast,\n Medium\n"))); + Assert.That(result.Notes, Has.One.Contains("added at the end")); + } + + /***************************************************/ + + [Test] + [Description("Checks that enum members with explicit values are added, removed and renumbered in place, with descriptions. Protects the enum member changes.")] + public void EnumMembersWithValuesAreAddedRemovedAndRenumbered() + { + PatchResult result = Patch(Demo, "Coded", "BH.oM.Demo", new List { "System.ComponentModel" }, + new Change { Kind = "addEnumMember", Name = "C", Value = "3", After = "B", Description = "\"Third.\"" }, + new Change { Kind = "removeEnumMember", Name = "A" }, + new Change { Kind = "setEnumValue", Name = "B", Value = "5" }); + + Assert.That(result.Text, Is.EqualTo(Demo.Replace(" A = 1,\n B = 2,\n", " B = 5,\n [Description(\"Third.\")]\n C = 3,\n"))); + Assert.That(result.Notes, Is.Empty); + } + + /***************************************************/ + + [Test] + [Description("Checks that missing using directives are added once and existing ones are not duplicated. Protects the usings that the changes require.")] + public void MissingUsingsAreAddedOnce() + { + string text = Patch(Demo, "Demo", "BH.oM.Demo", new List { "System.ComponentModel", "BH.oM.Quantities.Attributes" }, + new Change { Kind = "addProperty", Name = "Depth", Type = "double", After = "Width", Add = { "Length" } }).Text; + Assert.That(text, Does.StartWith("using BH.oM.Base;\nusing System.ComponentModel;\nusing BH.oM.Quantities.Attributes;\n\nnamespace")); + } + + /***************************************************/ + + [Test] + [Description("Checks that a property added to an immutable class is get-only and produces a note about the constructor. Protects patching of immutable types.")] + public void GetOnlyClassesGetGetOnlyPropertiesAndANote() + { + string source = Lf(""" + namespace BH.oM.Demo + { + public class Fixed : IImmutable + { + public virtual string Id { get; } + + public Fixed(string id) + { + Id = id; + } + } + } + + """); + PatchResult result = Patch(source, "Fixed", "BH.oM.Demo", null, new Change { Kind = "addProperty", Name = "Extra", Type = "string", After = "Id" }); + Assert.That(result.Text, Is.EqualTo(source.Replace(" public virtual string Id { get; }\n", " public virtual string Id { get; }\n\n public virtual string Extra { get; }\n"))); + Assert.That(result.Notes, Has.One.Contains("constructor")); + } + + /***************************************************/ + + [Test] + [Description("Checks that properties are added to interfaces without a modifier and inside regions in the right place. Protects patching of interfaces and " + + "region-structured files.")] + public void InterfacesAndRegionsKeepTheirShape() + { + string source = Lf(""" + namespace BH.oM.Demo + { + public interface IThing : IObject + { + string Label { get; set; } + } + + public class Regioned : BHoMObject + { + #region Properties + + public virtual int A { get; set; } = 0; + + #endregion + } + } + + """); + string inInterface = Patch(source, "IThing", "BH.oM.Demo", null, new Change { Kind = "addProperty", Name = "Count", Type = "int", Initialiser = "3", After = "Label" }).Text; + Assert.That(inInterface, Is.EqualTo(source.Replace(" string Label { get; set; }\n", " string Label { get; set; }\n\n int Count { get; set; }\n"))); + + string inRegion = Patch(source, "Regioned", "BH.oM.Demo", null, new Change { Kind = "addProperty", Name = "B", Type = "int", Initialiser = "0", After = "A" }).Text; + Assert.That(inRegion, Is.EqualTo(source.Replace(" public virtual int A { get; set; } = 0;\n", " public virtual int A { get; set; } = 0;\n\n public virtual int B { get; set; } = 0;\n"))); + } + + /***************************************************/ + + [Test] + [Description("Checks that patching a CRLF file adds only CRLF line endings. Protects files that use Windows line endings from mixed endings.")] + public void WindowsLineEndingsAreKept() + { + string source = Demo.Replace("\n", "\r\n"); + string text = Patch(source, "Demo", "BH.oM.Demo", null, new Change { Kind = "addProperty", Name = "Height", Type = "double", After = "Width" }).Text; + Assert.That(text.Replace("\r\n", ""), Does.Not.Contain("\n")); + Assert.That(text, Does.Contain("= 1.5;\r\n\r\n public virtual double Height { get; set; }\r\n")); + } + + /***************************************************/ + + [Test] + [Description("Checks that a missing type throws and a missing member leaves the file unchanged with a note. Protects the patcher from guessing when the C# " + + "differs from what py2cs expected.")] + public void AMissingTypeOrMemberIsReportedNotGuessed() + { + Assert.Throws(() => Patch(Demo, "Nowhere")); + PatchResult result = Patch(Demo, "Demo", "BH.oM.Demo", null, new Change { Kind = "removeProperty", Name = "Ghost" }); + Assert.That(result.Text, Is.EqualTo(Demo)); + Assert.That(result.Notes, Has.One.Contains("Ghost was not found")); + } + + /***************************************************/ + + [Description("Builds a file job for the given type, namespace, usings and changes and runs the patcher on the source, returning the patched text and " + + "notes. Used by every test in this class.")] + private static PatchResult Patch(string source, string type, string ns = "BH.oM.Demo", List usings = null, params Change[] changes) + { + FileJob job = new FileJob { Type = type, Namespace = ns, Usings = usings ?? new List(), Changes = changes.ToList() }; + return FilePatcher.Patch(source, job); + } + + /***************************************************/ + + [Description("Converts line endings of the text to LF so raw-string sources are the same on every platform. Used for the demo sources in this class.")] + private static string Lf(string text) + { + return text.Replace("\r\n", "\n"); + } + + /***************************************************/ + + [Description("Counts how many times a part occurs in a text. Used to check how many DocumentationURL attributes remain after a removal.")] + private static int Count(string text, string part) + { + return (text.Length - text.Replace(part, "").Length) / part.Length; + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PythonSchemaGeneration.Tests.csproj b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PythonSchemaGeneration.Tests.csproj new file mode 100644 index 0000000..47c7ab6 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/PythonSchemaGeneration.Tests.csproj @@ -0,0 +1,43 @@ + + + + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..', '..', '..', '..', 'CSharpSchema_Test')) + $([MSBuild]::EnsureTrailingSlash('$(CSharpSchemaTestRoot)')) + + + + net8.0 + enable + disable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RealTypeTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RealTypeTests.cs new file mode 100644 index 0000000..4d2d0d2 --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RealTypeTests.cs @@ -0,0 +1,136 @@ +using PythonSchemaGeneration; +using PythonSchemaGeneration.Emission; +using PythonSchemaGeneration.Model; + +namespace PythonSchemaGeneration.Tests +{ + //Tier 2: the real oM assemblies installed in ProgramData\BHoM\Assemblies. Fails in CI, ignored locally when something is missing. + [Category("RealTypes")] + public class RealTypeTests + { + private static GenerationResult m_Result; + + [OneTimeSetUp] + [Description("Skips or fails the fixture when the real BHoM assemblies are missing and otherwise generates the spike set once. Prepares the shared result " + + "for the tests in this class.")] + public void Setup() + { + RealAssemblies.RequireOrSkip(); + m_Result = RealAssemblies.Spike(); + } + + [Description("Returns the generated spike type with the given CLR name. The tests in this class use it to inspect real BHoM types.")] + private static RenderedType R(string clrName) { return m_Result.Types.Single(t => t.Model.ClrName == clrName); } + + [Description("Joins the simple names of the given types with commas so a type's bases can be compared as one string. Used by the base-type tests.")] + private static string Names(IEnumerable types) { return string.Join(",", types.Select(t => t.Name)); } + + /***************************************************/ + + [Test] + [Description("Checks that every spike type is generated from the real assemblies and that the run produces no warnings. Protects the generator against " + + "real oM types.")] + public void EverySpikeTypeIsGeneratedWithoutWarnings() + { + foreach (string name in RealAssemblies.SpikeTypes) + Assert.That(m_Result.Types.Any(t => t.Model.ClrName == name), Is.True, name); + Assert.That(m_Result.Reporter.Warnings, Is.Empty); + } + + [Test] + [Description("Checks that Line and ICurve get the expected bases, that Line's defaults, dependencies and path are generated. Protects the base and default " + + "handling on a real geometry type.")] + public void LineAndTheCurveInterfaceChain() + { + Assert.That(Names(R("BH.oM.Geometry.Line").Model.Bases), Is.EqualTo("ICurve")); + Assert.That(Names(R("BH.oM.Geometry.ICurve").Model.Bases), Is.EqualTo("IElement1D,IGeometry")); + Assert.That(R("BH.oM.Geometry.Line").Text, Does.Contain("Start: Point = Field(default_factory=lambda: Point())")); + Assert.That(m_Result.Types.Any(t => t.Model.ClrName == "BH.oM.Geometry.Point"), Is.True, "dependencies are generated too"); + Assert.That(R("BH.oM.Geometry.Line").Ref.RelativePath, Is.EqualTo("Geometry_oM/Line")); + } + + [Test] + [Description("Checks that BHoMObject gets its GUID, tags, custom data and fragment fields with the expected defaults and CLR types. Protects the base " + + "class that most generated types inherit.")] + public void BHoMObjectHasDefaultedFields() + { + string text = R("BH.oM.Base.BHoMObject").Text; + Assert.That(text, Does.Contain("BHoM_Guid: UUID = Field(default_factory=uuid4)")); + Assert.That(text, Does.Contain("Tags: set[str] = Field(default_factory=set)")); + Assert.That(text, Does.Contain("CustomData: dict[str, Any] = Field(default_factory=dict)")); + Assert.That(text, Does.Contain("ClrType('BH.oM.Base.FragmentSet')")); + } + + [Test] + [Description("Checks that Bar derives from BHoMObject and carries quantity metadata for angle properties. Protects quantity extraction on a real structural type.")] + public void BarSubclassesBHoMObjectAndCarriesQuantities() + { + RenderedType bar = R("BH.oM.Structure.Elements.Bar"); + Assert.That(Names(bar.Model.Bases), Does.StartWith("BHoMObject")); + Assert.That(bar.Properties.Any(p => p.Quantity != null && p.Quantity.Category == "Angle"), Is.True); + Assert.That(bar.Text, Does.Contain("Quantity('Angle'")); + } + + [Test] + [Description("Checks that a real enum with a None member is renamed to None_ and records the original name. Protects enum emission on a real type.")] + public void EnumWithNoneMember() + { + string text = R("BH.oM.Graphics.Enums.GradientCenteringOptions").Text; + Assert.That(text, Does.Contain("None_ = 0")); + Assert.That(text, Does.Contain("'renamed_members': {'None_': 'None'}")); + } + + [Test] + [Description("Checks that the real generic BHoMGroup is named with its arity and keeps its type parameter bound. Protects generics handling on a real type.")] + public void GenericWithConstraint() + { + RenderedType group = R("BH.oM.Base.BHoMGroup`1"); + Assert.That(group.Ref.Name, Is.EqualTo("BHoMGroup_1")); + Assert.That(group.Text, Does.Contain("class BHoMGroup_1[T: Polymorphic[IBHoMObject]](")); + } + + [Test] + [Description("Checks that the two Room types from different assemblies get different module paths. Protects TypeRegistry placement of same-named real types.")] + public void SameNamedRoomsLiveInDifferentAssemblies() + { + Assert.That(R("BH.oM.Acoustic.Room").Ref.RelativePath, Is.EqualTo("Acoustic_oM/Room")); + Assert.That(R("BH.oM.Architecture.Elements.Room").Ref.RelativePath, Is.EqualTo("Architecture_oM/Elements/Room")); + } + + [Test] + [Description("Checks that Gradient's dictionary of decimal to colour is written with special types imported from the support package. Protects TypeMapper " + + "and imports for special types.")] + public void GradientUsesNonHashableAndSpecialTypes() + { + string text = R("BH.oM.Graphics.Gradient").Text; + Assert.That(text, Does.Contain("dict[Decimal, Color]")); + Assert.That(text, Does.Contain("from bhom_schema._support import")); + } + + [Test] + [Description("Checks that a hash set of oM objects is written as a list with the original type kept. Protects the hashable fallback on a real type.")] + public void ComparisonConfigHasSetsOfObjects() + { + string text = R("BH.oM.Base.BaseComparisonConfig").Text; + Assert.That(text, Does.Contain("PropertyNumericTolerances: Annotated[list[NamedNumericTolerance], ClrType('System.Collections.Generic.HashSet')]")); + } + + [Test] + [Description("Checks that the spike types considered stable match their committed golden files. Protects the exact output for real types.")] + public void StableTypesMatchGoldenFiles() + { + foreach (string name in new[] { "BH.oM.Graphics.Enums.GradientCenteringOptions" }) + { + RenderedType type = R(name); + GoldenAssert.Matches("spike", type.Ref.RelativePath + ".py", type.Text); + } + } + + [Test] + [Description("Checks that the spike set contains no cyclic references between generated types. Protects the CycleDetector result for the spike set.")] + public void SpikeOutputHasNoReferenceCycles() + { + Assert.That(m_Result.CyclicTypes, Is.EqualTo(0)); + } + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RepoFilterTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RepoFilterTests.cs new file mode 100644 index 0000000..a05efed --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RepoFilterTests.cs @@ -0,0 +1,103 @@ +using PythonSchemaGeneration.Emission; + +namespace PythonSchemaGeneration.Tests +{ + //--repos: convert the assemblies of some repositories, plus what they depend on in other repositories + public class RepoFilterTests + { + private const string FixtureRepo = "BHoM/CSharpSchema_Test"; + + /***************************************************/ + + [Description("Builds generator options for the fixture and BHoM assemblies limited to one repository, with an optional output folder. Used by every test " + + "in this class.")] + private static GenerationOptions Options(string repo, string output = null) + { + return new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha, FixtureOutput.Beta, typeof(BH.oM.Base.BHoMObject).Assembly }, + Repos = new List { repo }, + OutputFolder = output, + }; + } + + /***************************************************/ + + [Test] + [Description("Checks that a repository run converts that repository's types plus only the base classes it depends on in other repositories. Protects the " + + "--repos filter of the generator.")] + public void OnlyTheRequestedRepositoryIsConvertedPlusItsDependencies() + { + GenerationResult result = Generator.Run(Options(FixtureRepo)); + List others = result.Types.Where(t => !t.Model.ClrName.StartsWith("BH.oM.Fixture")).ToList(); + + Assert.That(result.Types.Count(t => t.Model.ClrName.StartsWith("BH.oM.Fixture")), Is.GreaterThan(20)); + Assert.That(others.Select(t => t.Model.Assembly).Distinct(), Is.EqualTo(new[] { "BHoM" }), "only base classes from BHoM, nothing else"); + Assert.That(others.Any(t => t.Model.ClrName == "BH.oM.Base.BHoMObject"), Is.True); + Assert.That(others.Count, Is.LessThan(20), "the rest of the BHoM assembly is not converted"); + } + + /***************************************************/ + + [Test] + [Description("Checks that the repository filter matches names regardless of case. Protects the --repos option against capitalisation differences.")] + public void RepositoryNamesAreMatchedWithoutRegardToCase() + { + Assert.That(Generator.Run(Options("bhom/csharpschema_test")).Types.Any(t => t.Model.ClrName == "BH.oM.FixtureAlpha.Beam"), Is.True); + } + + /***************************************************/ + + [Test] + [Description("Checks that a repository with no assembly produces a warning and converts no types. Protects the --repos option from silently doing nothing.")] + public void ARepositoryWithNoAssemblyIsReportedAndConvertsNothing() + { + GenerationResult result = Generator.Run(Options("BHoM/Nowhere")); + Assert.That(result.Types, Is.Empty); + Assert.That(result.Reporter.Warnings, Has.Some.Contain("BHoM/Nowhere")); + } + + /***************************************************/ + + [Test] + [Description("Checks that the implementers an interface lists are only the classes that are generated: a run for one repository lists no class of the repositories " + + "it leaves out. Protects the Polymorphic type from naming a class that is not in the package.")] + public void InterfacesListOnlyTheClassesThatAreGenerated() + { + GenerationResult result = Generator.Run(Options(FixtureRepo)); + string fragment = result.Types.Single(t => t.Model.ClrName == "BH.oM.Base.IFragment").Text; + Assert.That(fragment, Does.Contain("'implementers': {},"), "no fragment class is generated in this run"); + + string curve = result.Types.Single(t => t.Model.ClrName == "BH.oM.FixtureAlpha.ICurveLike").Text; + Assert.That(curve, Does.Contain("'BH.oM.FixtureAlpha.LineLike'")); + } + + [Test] + [Description("Checks that stale files are removed only for the requested repository's assemblies, files of other repositories stay and needed base classes " + + "are written. Protects OutputWriter in per-repository runs.")] + public void CleanUpOnlyTouchesTheAssembliesOfTheRequestedRepository() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-repos-" + Guid.NewGuid().ToString("N")); + try + { + string package = Path.Combine(folder, "bhom_schema"); + Directory.CreateDirectory(Path.Combine(package, "FixtureAlpha_oM")); + Directory.CreateDirectory(Path.Combine(package, "BHoM")); + File.WriteAllText(Path.Combine(package, "FixtureAlpha_oM", "Stale.py"), "x = 1\n"); + File.WriteAllText(Path.Combine(package, "BHoM", "FromAnotherRun.py"), "x = 1\n"); + + Generator.Run(Options(FixtureRepo, package)); + + Assert.That(File.Exists(Path.Combine(package, "FixtureAlpha_oM", "Stale.py")), Is.False, "stale file of the requested repository is removed"); + Assert.That(File.Exists(Path.Combine(package, "BHoM", "FromAnotherRun.py")), Is.True, "files of the dependency's own repository stay"); + Assert.That(File.Exists(Path.Combine(package, "BHoM", "BHoMObject.py")), Is.True, "the base class that the fixtures import is written"); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RoundTripTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RoundTripTests.cs new file mode 100644 index 0000000..738e8ed --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/RoundTripTests.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PythonSchemaGeneration; +using PythonSchemaGeneration.Loading; + +namespace PythonSchemaGeneration.Tests +{ + //Python to C# round trip: the C# drafted from the generated Python must compile, and reflect back to the same types. + //The drafts are written by `python -m py2cs drafts` (see run-all.ps1); the comparison with the original manifest is done by pytest. + [Category("RoundTrip")] + public class RoundTripTests + { + [TestCase("fixtures")] + [TestCase("spike")] + [Description("Checks that the C# drafted by py2cs from the generated Python for the given set compiles with Roslyn and that the generator reflects every " + + "drafted type back from the compiled assembly. Protects the Python to C# direction of the round trip.")] + public void DraftsCompileAndReflectBack(string set) + { + if (set == "spike") + RealAssemblies.RequireOrSkip(); + + string folder = TestPaths.Output(Path.Combine("roundtrip", set)); + if (!Directory.Exists(folder)) + { + string message = $"No drafts in {folder}: run `python -m py2cs drafts` first"; + if (string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase)) + Assert.Fail(message); + Assert.Ignore(message); + } + + List trees = Directory.GetFiles(folder, "*.cs", SearchOption.AllDirectories) + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), path: f)) + .ToList(); + Assert.That(trees.Count, Is.GreaterThan(10)); + trees.Add(CSharpSyntaxTree.ParseText("[assembly: System.Reflection.AssemblyDescription(\"https://github.com/BHoM/Draft_Toolkit\")]")); + + CSharpCompilation compilation = CSharpCompilation.Create("Draft_oM", trees, References(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Disable)); + + using MemoryStream stream = new MemoryStream(); + var result = compilation.Emit(stream); + List errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()).ToList(); + Assert.That(errors, Is.Empty, string.Join("\n", errors.Take(20))); + + stream.Position = 0; + AssemblyLoadContext context = new AssemblyLoadContext("draft-" + set, true); + try + { + Assembly draft = context.LoadFromStream(stream); + string manifest = Path.Combine(folder, "draft-manifest.json"); + GenerationResult generated = Generator.Run(new GenerationOptions + { + Assemblies = new List { draft }, + ManifestPath = manifest, + }); + Assert.That(File.Exists(manifest), Is.True); + Assert.That(generated.Types.Count, Is.EqualTo(trees.Count - 1), "every draft file declares one type that reflects back"); + } + finally + { + context.Unload(); + } + } + + /***************************************************/ + + [Description("Builds the compiler references (framework assemblies, BHoM, System.Drawing and Quantities_oM) used to compile the drafted C#. Used by " + + "DraftsCompileAndReflectBack.")] + private static List References() + { + string[] trusted = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")).Split(Path.PathSeparator); + List paths = trusted + .Where(p => Path.GetFileName(p).StartsWith("System.") || Path.GetFileName(p).StartsWith("Microsoft.") || Path.GetFileName(p) is "netstandard.dll" or "mscorlib.dll") + .ToList(); + paths.Add(typeof(BH.oM.Base.BHoMObject).Assembly.Location); + paths.Add(typeof(System.Drawing.Color).Assembly.Location); + string quantities = Path.Combine(BHoMAdapter.DefaultAssemblyFolder(), "Quantities_oM.dll"); + if (File.Exists(quantities)) + paths.Add(quantities); + return paths.Distinct().Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); + } + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/TestSupport.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/TestSupport.cs new file mode 100644 index 0000000..68e49af --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/TestSupport.cs @@ -0,0 +1,163 @@ +using System.Reflection; +using BH.oM.Base; +using PythonSchemaGeneration; +using PythonSchemaGeneration.Emission; +using PythonSchemaGeneration.Extraction; +using PythonSchemaGeneration.Loading; + +namespace PythonSchemaGeneration.Tests +{ + public static class TestPaths + { + public static string Root + { + get + { + string dir = AppContext.BaseDirectory; + while (dir != null && !Directory.Exists(Path.Combine(dir, ".ci"))) + dir = Path.GetDirectoryName(dir); + return dir ?? throw new InvalidOperationException("Repository root (folder containing .ci) not found"); + } + } + + [Description("Returns the path of a file or folder inside .ci/unit-tests/_output, where the NUnit tests write the generated Python and manifests that the " + + "pytest suites read.")] + public static string Output(string name) { return Path.Combine(Root, ".ci", "unit-tests", "_output", name); } + + [Description("Returns the path of a file inside .ci/unit-tests/golden, which holds the expected generator output and the patcher input files used by " + + "GoldenAssert and PatcherTests.")] + public static string Golden(string name) { return Path.Combine(Root, ".ci", "unit-tests", "golden", name); } + + public static string SupportPackage { get { return Path.Combine(Root, "src", "bhom_schema"); } } + } + + //Generates the Python package for the fixture assemblies once, into _output/fixtures, where the pytest tests pick it up + public static class FixtureOutput + { + private static readonly object m_Lock = new object(); + private static GenerationResult m_Result; + + public static Assembly Alpha { get { return typeof(BH.oM.FixtureAlpha.PointLike).Assembly; } } + public static Assembly Beta { get { return typeof(BH.oM.FixtureBeta.PointLike).Assembly; } } + + public static string Folder { get { return TestPaths.Output("fixtures"); } } + public static string Package { get { return Path.Combine(Folder, "bhom_schema"); } } + + public static GenerationResult Result + { + get + { + lock (m_Lock) + { + if (m_Result == null) + m_Result = Generate(Folder, true); + return m_Result; + } + } + } + + [Description("Recreates the folder, copies the hand-written support package into it and runs the generator over the fixture assemblies, optionally writing " + + "the manifest and report. FixtureOutput.Result uses it for the output the pytest suites read, and the determinism test uses it for two " + + "throw-away runs.")] + public static GenerationResult Generate(string folder, bool withManifest) + { + if (Directory.Exists(folder)) + Directory.Delete(folder, true); + Directory.CreateDirectory(folder); + CopySupport(Path.Combine(folder, "bhom_schema")); + + List assemblies = new List { Alpha, Beta, typeof(BHoMObject).Assembly }; + GenerationOptions options = new GenerationOptions + { + Assemblies = assemblies, + OutputFolder = Path.Combine(folder, "bhom_schema"), + TypeFilter = TypeSelector.Select(new[] { Alpha, Beta }).Select(t => t.FullName).ToList(), + ManifestPath = withManifest ? Path.Combine(folder, "manifest.json") : null, + ReportPath = withManifest ? Path.Combine(folder, "report.txt") : null, + }; + return Generator.Run(options); + } + + [Description("Copies the hand-written bhom_schema __init__, _version and _support modules into the given package folder, because the generated modules import them. " + + "Every test that generates Python into a folder calls it first.")] + public static void CopySupport(string package) + { + Directory.CreateDirectory(package); + File.Copy(Path.Combine(TestPaths.SupportPackage, "__init__.py"), Path.Combine(package, "__init__.py"), true); + File.Copy(Path.Combine(TestPaths.SupportPackage, "_version.py"), Path.Combine(package, "_version.py"), true); + string support = Path.Combine(package, "_support"); + Directory.CreateDirectory(support); + foreach (string file in Directory.GetFiles(Path.Combine(TestPaths.SupportPackage, "_support"), "*.py")) + File.Copy(file, Path.Combine(support, Path.GetFileName(file)), true); + } + + [Description("Returns the generated type with the given CLR name from the shared fixture run. The fixture extraction tests use it to inspect the Python " + + "text and properties of one type.")] + public static RenderedType Rendered(string clrName) + { + return Result.Types.Single(t => t.Model.ClrName == clrName); + } + } + + //Real BHoM assemblies: the tests fail in CI and are ignored locally when an assembly is missing + public static class RealAssemblies + { + public static readonly string[] Required = + { + "BHoM", "Geometry_oM", "Structure_oM", "Graphics_oM", "Acoustic_oM", "Architecture_oM", "Quantities_oM", + "BHoM_Engine", "Reflection_Engine", "JsonSchema_Engine", "JsonSchema_oM" + }; + + [Description("Lists the required real BHoM assemblies that are not in the default BHoM assembly folder. RequireOrSkip uses it to decide whether the " + + "real-type tests can run.")] + public static List Missing() + { + string folder = BHoMAdapter.DefaultAssemblyFolder(); + return Required.Where(a => !File.Exists(Path.Combine(folder, a + ".dll"))).ToList(); + } + + [Description("Returns when all required real BHoM assemblies are installed; otherwise fails the test when the CI environment variable is true and ignores " + + "it locally, with a message naming the missing assemblies. The real-type and spike round-trip tests call it so CI never skips them silently.")] + public static void RequireOrSkip() + { + List missing = Missing(); + if (missing.Count == 0) + return; + string message = "Missing BHoM assemblies in " + BHoMAdapter.DefaultAssemblyFolder() + ": " + string.Join(", ", missing); + if (string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase)) + Assert.Fail(message); + Assert.Ignore(message); + } + + public static readonly string[] SpikeTypes = + { + "BH.oM.Geometry.Line", "BH.oM.Geometry.ICurve", "BH.oM.Base.BHoMObject", + "BH.oM.Structure.Elements.Bar", "BH.oM.Graphics.Enums.GradientCenteringOptions", "BH.oM.Base.BHoMGroup`1", + "BH.oM.Acoustic.Room", "BH.oM.Architecture.Elements.Room", "BH.oM.Graphics.Gradient", "BH.oM.Base.ComparisonConfig" + }; + + private static GenerationResult m_Spike; + + [Description("Generates the Python (with support package, manifest and report) for the fixed set of real BHoM spike types from the installed assemblies " + + "into _output/spike, once, and returns the cached result. RealTypeTests and the pytest spike suite read it.")] + public static GenerationResult Spike() + { + if (m_Spike != null) + return m_Spike; + string folder = TestPaths.Output("spike"); + if (Directory.Exists(folder)) + Directory.Delete(folder, true); + Directory.CreateDirectory(folder); + FixtureOutput.CopySupport(Path.Combine(folder, "bhom_schema")); + m_Spike = Generator.Run(new GenerationOptions + { + AssemblyFolder = BHoMAdapter.DefaultAssemblyFolder(), + OutputFolder = Path.Combine(folder, "bhom_schema"), + TypeFilter = SpikeTypes.ToList(), + ManifestPath = Path.Combine(folder, "manifest.json"), + ReportPath = Path.Combine(folder, "report.txt"), + }); + return m_Spike; + } + } +} diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/VersionTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/VersionTests.cs new file mode 100644 index 0000000..6adab3a --- /dev/null +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/VersionTests.cs @@ -0,0 +1,122 @@ +using PythonSchemaGeneration.Extraction; +using PythonSchemaGeneration.Loading; + +namespace PythonSchemaGeneration.Tests +{ + //The version of the package: read from BHoM.dll, written to _version.py, checked by --release + public class VersionTests + { + [Description("Builds options for a full run over the fixture repository and BHoM, with an optional output folder and release. Used by every test in this class.")] + private static GenerationOptions Options(string output, string release = null) + { + return new GenerationOptions + { + Assemblies = new List { FixtureOutput.Alpha, FixtureOutput.Beta, typeof(BH.oM.Base.BHoMObject).Assembly }, + Repos = new List { "BHoM/CSharpSchema_Test" }, + OutputFolder = output, + ReleaseVersion = release, + }; + } + + /***************************************************/ + + [Test] + [Description("Checks that a C# attribute class of the BHoM assembly is not selected for the package. Protects the schema from containing markers such as " + + "DisplayTextAttribute, which are not data.")] + public void AttributeClassesAreNotSelected() + { + Assert.That(TypeSelector.IsSelected(typeof(BH.oM.Base.Attributes.DisplayTextAttribute)), Is.False); + Assert.That(TypeSelector.Select(new[] { typeof(BH.oM.Base.BHoMObject).Assembly }).Where(t => typeof(Attribute).IsAssignableFrom(t)), Is.Empty); + } + + /***************************************************/ + + [Test] + [Description("Checks that the version is read as Major.Minor of the assembly version, from BHoM.dll when it is loaded, from another assembly otherwise, and is null " + + "without any. Protects the package version from following anything but the BHoM release.")] + public void TheVersionIsMajorDotMinorOfTheAssemblyVersion() + { + Version bhom = typeof(BH.oM.Base.BHoMObject).Assembly.GetName().Version; + Version alpha = FixtureOutput.Alpha.GetName().Version; + + Assert.That(BHoMAdapter.BHoMVersion(new[] { FixtureOutput.Alpha, typeof(BH.oM.Base.BHoMObject).Assembly }), Is.EqualTo(bhom.Major + "." + bhom.Minor)); + Assert.That(BHoMAdapter.BHoMVersion(new[] { FixtureOutput.Alpha }), Is.EqualTo(alpha.Major + "." + alpha.Minor)); + Assert.That(BHoMAdapter.BHoMVersion(new System.Reflection.Assembly[0]), Is.Null); + } + + /***************************************************/ + + [Test] + [Description("Checks that a full run writes _version.py with the BHoM version and a dev0 package version, and that a second run leaves the file alone. Protects " + + "the weekly run from making a commit for nothing.")] + public void ADevelopRunWritesTheVersionFileOnce() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-version-" + Guid.NewGuid().ToString("N")); + try + { + string package = Path.Combine(folder, "bhom_schema"); + GenerationResult first = Generator.Run(Options(package)); + string text = File.ReadAllText(Path.Combine(package, "_version.py")); + + Assert.That(text, Does.Contain($"__bhom_version__ = \"{first.BHoMVersion}\"")); + Assert.That(text, Does.Contain($"__version__ = \"{first.BHoMVersion}.0.dev0\"")); + + DateTime written = File.GetLastWriteTimeUtc(Path.Combine(package, "_version.py")); + Generator.Run(Options(package)); + Assert.That(File.GetLastWriteTimeUtc(Path.Combine(package, "_version.py")), Is.EqualTo(written)); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + /***************************************************/ + + [Test] + [Description("Checks that a release run for the BHoM version gives the plain version, and a release for another version fails and writes nothing. Protects a " + + "release from being named wrongly.")] + public void AReleaseMustNameTheBHoMVersionItReads() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-release-" + Guid.NewGuid().ToString("N")); + try + { + string package = Path.Combine(folder, "bhom_schema"); + string bhom = Generator.Run(Options(null)).BHoMVersion; + + Generator.Run(Options(package, bhom)); + Assert.That(File.ReadAllText(Path.Combine(package, "_version.py")), Does.Contain($"__version__ = \"{bhom}.0\"")); + + string other = Path.Combine(folder, "other"); + Assert.Throws(() => Generator.Run(Options(other, "99.9"))); + Assert.That(Directory.Exists(other), Is.False); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + /***************************************************/ + + [Test] + [Description("Checks that a run for some types only does not write _version.py. Protects the version file from being written by a partial run.")] + public void APartialRunLeavesTheVersionFileAlone() + { + string folder = Path.Combine(Path.GetTempPath(), "pysg-partial-" + Guid.NewGuid().ToString("N")); + try + { + GenerationOptions options = Options(Path.Combine(folder, "bhom_schema")); + options.Repos = null; + options.TypeFilter = new List { "BH.oM.FixtureAlpha.Beam" }; + Generator.Run(options); + + Assert.That(File.Exists(Path.Combine(folder, "bhom_schema", "_version.py")), Is.False); + } + finally + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + } +} diff --git a/.ci/unit-tests/github-fixtures/repo-listing.json b/.ci/unit-tests/github-fixtures/repo-listing.json new file mode 100644 index 0000000..9388736 --- /dev/null +++ b/.ci/unit-tests/github-fixtures/repo-listing.json @@ -0,0 +1,64 @@ +{ + "BHoM/BHoM": [ + "Acoustic_oM", + "Analytical_oM", + "Architecture_oM", + "BHoM", + "Data_oM", + "DeepLearning_oM", + "Diffing_oM", + "Dimensional_oM", + "Environment_oM", + "Facade_oM", + "Geometry_oM", + "Graphics_oM", + "Ground_oM", + "Humans_oM", + "Inspection_oM", + "LifeCycleAssessment_oM", + "Lighting_oM", + "MEP_oM", + "Matter_oM", + "Physical_oM", + "Planning_oM", + "Programming_oM", + "Quantities_oM", + "Search_oM", + "Security_oM", + "Spatial_oM", + "Structure_oM", + "Test_oM", + "Verification_oM", + "Versioning_oM" + ], + "BHoM/BHoM_Adapter": [ + "Adapter_oM" + ], + "BHoM/Clipper_Toolkit": [ + "Clipper_oM" + ], + "BHoM/Excel_Toolkit": [ + "Excel_oM" + ], + "BHoM/File_Toolkit": [ + "File_oM" + ], + "BHoM/JSONSchema_Toolkit": [ + "JsonSchema_oM" + ], + "BHoM/Python_Toolkit": [ + "Python_oM" + ], + "BHoM/Revit_Toolkit": [ + "Revit_oM" + ], + "BHoM/Test_Toolkit": [ + "CodeCompliance_oM", + "InteroperabilityTest_oM", + "NUnit_oM", + "UnitTest_oM" + ], + "BHoM/dKoP_Toolkit": [ + "dKoP_oM" + ] +} diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Beam.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Beam.py new file mode 100644 index 0000000..a3e5ded --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Beam.py @@ -0,0 +1,33 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Beam. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.Options import Options +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema.FixtureAlpha_oM.SupportKind import SupportKind +from bhom_schema._support import ClrType, DisplayText, Quantity + + +class Beam(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Beam', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Span: Annotated[float, Quantity('Length', 'm'), DisplayText('Span length')] = Field(default=1.5, description='Distance between the supports.') + Support: SupportKind = SupportKind.Pinned + Flags: Options = Options.A + Start: PointLike | None = None + Count: Annotated[int, ClrType('long')] = 3 + Small: Annotated[float, ClrType('float')] = 0.5 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Collections.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Collections.py new file mode 100644 index 0000000..7fd68fa --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Collections.py @@ -0,0 +1,35 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Collections. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema._support import ClrType + + +class Collections(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Collections', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Names: set[str] = Field(default_factory=set) + Points: Annotated[list[PointLike], ClrType('System.Collections.Generic.HashSet')] = Field(default_factory=list) + Lookup: dict[str, float] = Field(default_factory=dict) + ByPoint: Annotated[list[tuple[PointLike, str]], ClrType('System.Collections.Generic.Dictionary')] = Field(default_factory=list) + Values: Annotated[list[float], ClrType('double[]')] = Field(default_factory=list) + Matrix: list[list[int]] = Field(default_factory=list) + Words: Annotated[list[str], ClrType('System.Collections.Generic.IEnumerable')] = Field(default_factory=lambda: ['a', 'b']) + OptionalCount: int | None = None + Pair: tuple[int, str] | None = None + Anything: Any | None = None diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleA.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleA.py new file mode 100644 index 0000000..1bea8d6 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleA.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.CycleA. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class CycleA(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.CycleA', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Other: CycleB | None = None + + +from bhom_schema.FixtureAlpha_oM.CycleB import CycleB + +CycleA.model_rebuild() diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleB.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleB.py new file mode 100644 index 0000000..e865cb2 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/CycleB.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.CycleB. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class CycleB(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.CycleB', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Other: CycleA | None = None + + +from bhom_schema.FixtureAlpha_oM.CycleA import CycleA + +CycleB.model_rebuild() diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GeometryHolder.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GeometryHolder.py new file mode 100644 index 0000000..9a82c51 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GeometryHolder.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.GeometryHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.ICurveLike import ICurveLike +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike +from bhom_schema._support import Polymorphic + + +class GeometryHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.GeometryHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Geometry: Polymorphic[IGeometryLike] | None = None + Curves: list[Polymorphic[ICurveLike]] = Field(default_factory=list) diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GridHolder.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GridHolder.py new file mode 100644 index 0000000..1f17f28 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/GridHolder.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.GridHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema._support import ClrType + + +class GridHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.GridHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Matrix: Annotated[list[list[float]], ClrType('double[,]')] = Field(default_factory=lambda: [[0.0, 0.0], [0.0, 0.0]]) + Table: Annotated[dict[str, float], ClrType('System.Collections.ObjectModel.ReadOnlyDictionary')] = Field(default_factory=dict) diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ICurveLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ICurveLike.py new file mode 100644 index 0000000..08004ac --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ICurveLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ICurveLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.IElement1DLike import IElement1DLike +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike + + +class ICurveLike(IElement1DLike, IGeometryLike): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ICurveLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IElement1DLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IElement1DLike.py new file mode 100644 index 0000000..548d77b --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IElement1DLike.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IElement1DLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IElement1DLike(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IElement1DLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IGeometryLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IGeometryLike.py new file mode 100644 index 0000000..e469e36 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IGeometryLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IGeometryLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IGeometryLike(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IGeometryLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + 'BH.oM.FixtureAlpha.PointLike': 'FixtureAlpha_oM.PointLike', + }, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ILabelled.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ILabelled.py new file mode 100644 index 0000000..f2b664e --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ILabelled.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ILabelled. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class ILabelled(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ILabelled', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'properties': {'Label': {'type': 'string'}}, + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IOpeningLike_1.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IOpeningLike_1.py new file mode 100644 index 0000000..7dcc986 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IOpeningLike_1.py @@ -0,0 +1,20 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IOpeningLike`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IBHoMObject import IBHoMObject + + +class IOpeningLike_1[T](IBHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IOpeningLike`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'generic_params': {'T': []}, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IPanelLike_2.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IPanelLike_2.py new file mode 100644 index 0000000..0d23c7d --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/IPanelLike_2.py @@ -0,0 +1,21 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IPanelLike`2. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IBHoMObject import IBHoMObject +from bhom_schema.FixtureAlpha_oM.IOpeningLike_1 import IOpeningLike_1 + + +class IPanelLike_2[TEdge, TOpening: IOpeningLike_1[Any]](IBHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IPanelLike`2', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'generic_params': {'TEdge': [], 'TOpening': ['BH.oM.FixtureAlpha.IOpeningLike']}, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ImmutableThing.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ImmutableThing.py new file mode 100644 index 0000000..61b2167 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ImmutableThing.py @@ -0,0 +1,24 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ImmutableThing. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.BHoM.IImmutable import IImmutable + + +class ImmutableThing(BHoMObject, IImmutable): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ImmutableThing', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Id: str + Length: float diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/LineLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/LineLike.py new file mode 100644 index 0000000..4f419f7 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/LineLike.py @@ -0,0 +1,31 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.LineLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.FixtureAlpha_oM.ICurveLike import ICurveLike +from bhom_schema.FixtureAlpha_oM.ILabelled import ILabelled +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema._support import SchemaModel + + +class LineLike(SchemaModel, ICurveLike, ILabelled): + """A straight segment between two points.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.LineLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Start: PointLike = Field(default_factory=lambda: PointLike()) + End: PointLike = Field(default_factory=lambda: PointLike()) + Infinite: bool = Field(default=False, description='Extends the line to infinity.') + Label: str | None = None diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Options.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Options.py new file mode 100644 index 0000000..35f3851 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Options.py @@ -0,0 +1,24 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Options. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMFlags + + +class Options(BHoMFlags): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Options', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + 'flags': True, + } + + A = 1 + B = 2 + Both = 3 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/PointLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/PointLike.py new file mode 100644 index 0000000..efd3266 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/PointLike.py @@ -0,0 +1,25 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.PointLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike +from bhom_schema._support import SchemaModel + + +class PointLike(SchemaModel, IGeometryLike): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.PointLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + X: float = 0.0 + Y: float = 0.0 + Z: float = 0.0 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ProjectLike.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ProjectLike.py new file mode 100644 index 0000000..ac1f3dd --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ProjectLike.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ProjectLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.ThingIdentity import ThingIdentity +from bhom_schema._support import SchemaModel + + +class ProjectLike(SchemaModel, ThingIdentity): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ProjectLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + ProjectReference: str = '' + ProjectName: str = '' + ProjectLocation: str = '' + AdditionalReferences: str = '' diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Scalars.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Scalars.py new file mode 100644 index 0000000..42612a2 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Scalars.py @@ -0,0 +1,40 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Scalars. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Annotated, Any, ClassVar +from uuid import UUID + +from pydantic import Field +from pydantic.json_schema import SkipJsonSchema + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema._support import ClrType, Color + + +class Scalars(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Scalars', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Created: datetime = Field(default_factory=datetime.now) + Fixed: datetime = datetime(2020, 1, 2, 3, 4, 5, 0) + Key: UUID = UUID(int=0) + Cost: Decimal = Decimal('1.5') + Colour: Color = Field(default_factory=Color) + Duration: timedelta = timedelta(seconds=90.0) + Letter: Annotated[str, ClrType('char')] = 'x' + Quote: str = 'it\'s "here"' + TypeRef: Annotated[Any | None, ClrType('System.Type')] = None + Callback: Annotated[SkipJsonSchema[Callable[..., Any]] | None, ClrType('System.Func')] = None + Big: float = 1.7976931348623157e+308 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Sparse.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Sparse.py new file mode 100644 index 0000000..d2d6b36 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Sparse.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Sparse. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMEnum + + +class Sparse(BHoMEnum): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Sparse', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + } + + One = 1 + Two = 2 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseHolder.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseHolder.py new file mode 100644 index 0000000..211da26 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseHolder.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SparseHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.Sparse import Sparse + + +class SparseHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SparseHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Level: Sparse diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseParent.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseParent.py new file mode 100644 index 0000000..a510c0e --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SparseParent.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SparseParent. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.SparseHolder import SparseHolder + + +class SparseParent(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SparseParent', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Holder: SparseHolder diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SupportKind.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SupportKind.py new file mode 100644 index 0000000..46408db --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/SupportKind.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SupportKind. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMEnum + + +class SupportKind(BHoMEnum): + """Kinds of support.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SupportKind', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + 'renamed_members': {'None_': 'None'}, + } + + None_ = 0 + # Fully fixed. + Fixed = 1 + Pinned = 2 diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ThingIdentity.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ThingIdentity.py new file mode 100644 index 0000000..861dd35 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/ThingIdentity.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ThingIdentity. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class ThingIdentity(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ThingIdentity', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.ProjectLike': 'FixtureAlpha_oM.ProjectLike', + }, + } diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/TreeLike_1.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/TreeLike_1.py new file mode 100644 index 0000000..19b9aa3 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/TreeLike_1.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.TreeLike`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject + + +class TreeLike_1[T](BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.TreeLike`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + 'generic_params': {'T': []}, + } + + Children: dict[str, TreeLike_1[T]] = Field(default_factory=lambda: {}) + Values: list[T] = Field(default_factory=lambda: []) diff --git a/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Wrapper_1.py b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Wrapper_1.py new file mode 100644 index 0000000..4e06c66 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureAlpha_oM/Wrapper_1.py @@ -0,0 +1,27 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Wrapper`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.BHoM.IBHoMObject import IBHoMObject +from bhom_schema._support import Polymorphic + + +class Wrapper_1[T: Polymorphic[IBHoMObject]](BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Wrapper`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + 'generic_params': {'T': ['BH.oM.Base.IBHoMObject']}, + } + + Items: list[T] = Field(default_factory=lambda: []) diff --git a/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/Elements/UsesBoth.py b/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/Elements/UsesBoth.py new file mode 100644 index 0000000..26e0d9b --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/Elements/UsesBoth.py @@ -0,0 +1,25 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureBeta.Elements.UsesBoth. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema.FixtureBeta_oM.PointLike import PointLike as FixtureBeta_oM_PointLike + + +class UsesBoth(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureBeta.Elements.UsesBoth', + 'assembly': 'FixtureBeta_oM', + 'namespace': 'BH.oM.FixtureBeta.Elements', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + FromAlpha: PointLike | None = None + Local: FixtureBeta_oM_PointLike | None = None diff --git a/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/PointLike.py b/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/PointLike.py new file mode 100644 index 0000000..533ebe5 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/FixtureBeta_oM/PointLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureBeta.PointLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class PointLike(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureBeta.PointLike', + 'assembly': 'FixtureBeta_oM', + 'namespace': 'BH.oM.FixtureBeta', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + X: float = 0.0 diff --git a/.ci/unit-tests/golden/fixtures/manifest.json b/.ci/unit-tests/golden/fixtures/manifest.json new file mode 100644 index 0000000..588a0c4 --- /dev/null +++ b/.ci/unit-tests/golden/fixtures/manifest.json @@ -0,0 +1,1015 @@ +{ + "schemaVersion": 1, + "types": [ + { + "clrName": "BH.oM.Base.BHoMObject", + "name": "BH.oM.Base.BHoMObject", + "pythonName": "BHoMObject", + "pythonModule": "bhom_schema.BHoM.BHoMObject", + "relativePath": "BHoM/BHoMObject.py", + "namespace": "BH.oM.Base", + "assembly": "BHoM", + "kind": "class", + "bases": [ + "BH.oM.Base.IBHoMObject" + ], + "repo": "BHoM/BHoM", + "properties": [ + { + "name": "BHoM_Guid", + "clrType": "System.Guid", + "pyName": "BHoM_Guid", + "hasDefault": true, + "default": " uuid4" + }, + { + "name": "Name", + "clrType": "string", + "pyName": "Name", + "hasDefault": true, + "default": "''" + }, + { + "name": "Fragments", + "clrType": "BH.oM.Base.FragmentSet", + "pyName": "Fragments", + "hasDefault": true, + "default": " list" + }, + { + "name": "Tags", + "clrType": "System.Collections.Generic.HashSet", + "pyName": "Tags", + "hasDefault": true, + "default": " set" + }, + { + "name": "CustomData", + "clrType": "System.Collections.Generic.Dictionary", + "pyName": "CustomData", + "hasDefault": true, + "default": " dict" + } + ] + }, + { + "clrName": "BH.oM.Base.IBHoMObject", + "name": "BH.oM.Base.IBHoMObject", + "pythonName": "IBHoMObject", + "pythonModule": "bhom_schema.BHoM.IBHoMObject", + "relativePath": "BHoM/IBHoMObject.py", + "namespace": "BH.oM.Base", + "assembly": "BHoM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/BHoM", + "properties": [ + { + "name": "BHoM_Guid", + "clrType": "System.Guid" + }, + { + "name": "Name", + "clrType": "string" + }, + { + "name": "Fragments", + "clrType": "BH.oM.Base.FragmentSet" + }, + { + "name": "Tags", + "clrType": "System.Collections.Generic.HashSet" + }, + { + "name": "CustomData", + "clrType": "System.Collections.Generic.Dictionary" + } + ] + }, + { + "clrName": "BH.oM.Base.IFragment", + "name": "BH.oM.Base.IFragment", + "pythonName": "IFragment", + "pythonModule": "bhom_schema.BHoM.IFragment", + "relativePath": "BHoM/IFragment.py", + "namespace": "BH.oM.Base", + "assembly": "BHoM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/BHoM", + "description": "Describes objects that can exist independently or can be attached to other BHoM objects as part of their `FragmentSet` property.", + "properties": [] + }, + { + "clrName": "BH.oM.Base.IImmutable", + "name": "BH.oM.Base.IImmutable", + "pythonName": "IImmutable", + "pythonModule": "bhom_schema.BHoM.IImmutable", + "relativePath": "BHoM/IImmutable.py", + "namespace": "BH.oM.Base", + "assembly": "BHoM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/BHoM", + "properties": [] + }, + { + "clrName": "BH.oM.Base.IObject", + "name": "BH.oM.Base.IObject", + "pythonName": "IObject", + "pythonModule": "bhom_schema.BHoM.IObject", + "relativePath": "BHoM/IObject.py", + "namespace": "BH.oM.Base", + "assembly": "BHoM", + "kind": "interface", + "bases": [], + "repo": "BHoM/BHoM", + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.Beam", + "name": "BH.oM.FixtureAlpha.Beam", + "pythonName": "Beam", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Beam", + "relativePath": "FixtureAlpha_oM/Beam.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Span", + "clrType": "double", + "pyName": "Span", + "hasDefault": true, + "default": "1.5", + "description": "Distance between the supports.", + "quantity": { + "category": "Length", + "unit": "m" + }, + "displayText": "Span length" + }, + { + "name": "Support", + "clrType": "BH.oM.FixtureAlpha.SupportKind", + "pyName": "Support", + "hasDefault": true, + "default": "SupportKind.Pinned" + }, + { + "name": "Flags", + "clrType": "BH.oM.FixtureAlpha.Options", + "pyName": "Flags", + "hasDefault": true, + "default": "Options.A" + }, + { + "name": "Start", + "clrType": "BH.oM.FixtureAlpha.PointLike", + "pyName": "Start", + "hasDefault": true, + "default": "None" + }, + { + "name": "Count", + "clrType": "long", + "pyName": "Count", + "hasDefault": true, + "default": "3" + }, + { + "name": "Small", + "clrType": "float", + "pyName": "Small", + "hasDefault": true, + "default": "0.5" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.Collections", + "name": "BH.oM.FixtureAlpha.Collections", + "pythonName": "Collections", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Collections", + "relativePath": "FixtureAlpha_oM/Collections.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Names", + "clrType": "System.Collections.Generic.HashSet", + "pyName": "Names", + "hasDefault": true, + "default": " set" + }, + { + "name": "Points", + "clrType": "System.Collections.Generic.HashSet", + "pyName": "Points", + "hasDefault": true, + "default": " list" + }, + { + "name": "Lookup", + "clrType": "System.Collections.Generic.Dictionary", + "pyName": "Lookup", + "hasDefault": true, + "default": " dict" + }, + { + "name": "ByPoint", + "clrType": "System.Collections.Generic.Dictionary", + "pyName": "ByPoint", + "hasDefault": true, + "default": " list" + }, + { + "name": "Values", + "clrType": "double[]", + "pyName": "Values", + "hasDefault": true, + "default": " list" + }, + { + "name": "Matrix", + "clrType": "System.Collections.Generic.List>", + "pyName": "Matrix", + "hasDefault": true, + "default": " list" + }, + { + "name": "Words", + "clrType": "System.Collections.Generic.IEnumerable", + "pyName": "Words", + "hasDefault": true, + "default": " lambda: ['a', 'b']" + }, + { + "name": "OptionalCount", + "clrType": "int?", + "pyName": "OptionalCount", + "hasDefault": true, + "default": "None" + }, + { + "name": "Pair", + "clrType": "System.Tuple", + "pyName": "Pair", + "hasDefault": true, + "default": "None" + }, + { + "name": "Anything", + "clrType": "object", + "pyName": "Anything", + "hasDefault": true, + "default": "None" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.CycleA", + "name": "BH.oM.FixtureAlpha.CycleA", + "pythonName": "CycleA", + "pythonModule": "bhom_schema.FixtureAlpha_oM.CycleA", + "relativePath": "FixtureAlpha_oM/CycleA.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Other", + "clrType": "BH.oM.FixtureAlpha.CycleB", + "pyName": "Other", + "hasDefault": true, + "default": "None" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.CycleB", + "name": "BH.oM.FixtureAlpha.CycleB", + "pythonName": "CycleB", + "pythonModule": "bhom_schema.FixtureAlpha_oM.CycleB", + "relativePath": "FixtureAlpha_oM/CycleB.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Other", + "clrType": "BH.oM.FixtureAlpha.CycleA", + "pyName": "Other", + "hasDefault": true, + "default": "None" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.GeometryHolder", + "name": "BH.oM.FixtureAlpha.GeometryHolder", + "pythonName": "GeometryHolder", + "pythonModule": "bhom_schema.FixtureAlpha_oM.GeometryHolder", + "relativePath": "FixtureAlpha_oM/GeometryHolder.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Geometry", + "clrType": "BH.oM.FixtureAlpha.IGeometryLike", + "pyName": "Geometry", + "hasDefault": true, + "default": "None" + }, + { + "name": "Curves", + "clrType": "System.Collections.Generic.List", + "pyName": "Curves", + "hasDefault": true, + "default": " list" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.GridHolder", + "name": "BH.oM.FixtureAlpha.GridHolder", + "pythonName": "GridHolder", + "pythonModule": "bhom_schema.FixtureAlpha_oM.GridHolder", + "relativePath": "FixtureAlpha_oM/GridHolder.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Matrix", + "clrType": "double[,]", + "pyName": "Matrix", + "hasDefault": true, + "default": " lambda: [[0.0, 0.0], [0.0, 0.0]]" + }, + { + "name": "Table", + "clrType": "System.Collections.ObjectModel.ReadOnlyDictionary", + "pyName": "Table", + "hasDefault": true, + "default": " dict" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.ICurveLike", + "name": "BH.oM.FixtureAlpha.ICurveLike", + "pythonName": "ICurveLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.ICurveLike", + "relativePath": "FixtureAlpha_oM/ICurveLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.FixtureAlpha.IElement1DLike", + "BH.oM.FixtureAlpha.IGeometryLike" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.IElement1DLike", + "name": "BH.oM.FixtureAlpha.IElement1DLike", + "pythonName": "IElement1DLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.IElement1DLike", + "relativePath": "FixtureAlpha_oM/IElement1DLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.IGeometryLike", + "name": "BH.oM.FixtureAlpha.IGeometryLike", + "pythonName": "IGeometryLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.IGeometryLike", + "relativePath": "FixtureAlpha_oM/IGeometryLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.ILabelled", + "name": "BH.oM.FixtureAlpha.ILabelled", + "pythonName": "ILabelled", + "pythonModule": "bhom_schema.FixtureAlpha_oM.ILabelled", + "relativePath": "FixtureAlpha_oM/ILabelled.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Label", + "clrType": "string" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.IOpeningLike`1", + "name": "BH.oM.FixtureAlpha.IOpeningLike", + "pythonName": "IOpeningLike_1", + "pythonModule": "bhom_schema.FixtureAlpha_oM.IOpeningLike_1", + "relativePath": "FixtureAlpha_oM/IOpeningLike_1.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IBHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "genericParams": [ + { + "name": "T", + "constraints": [] + } + ], + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.IPanelLike`2", + "name": "BH.oM.FixtureAlpha.IPanelLike", + "pythonName": "IPanelLike_2", + "pythonModule": "bhom_schema.FixtureAlpha_oM.IPanelLike_2", + "relativePath": "FixtureAlpha_oM/IPanelLike_2.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IBHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "genericParams": [ + { + "name": "TEdge", + "constraints": [] + }, + { + "name": "TOpening", + "constraints": [ + "BH.oM.FixtureAlpha.IOpeningLike" + ] + } + ], + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.ImmutableThing", + "name": "BH.oM.FixtureAlpha.ImmutableThing", + "pythonName": "ImmutableThing", + "pythonModule": "bhom_schema.FixtureAlpha_oM.ImmutableThing", + "relativePath": "FixtureAlpha_oM/ImmutableThing.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject", + "BH.oM.Base.IImmutable" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Id", + "clrType": "string", + "pyName": "Id", + "hasDefault": false + }, + { + "name": "Length", + "clrType": "double", + "pyName": "Length", + "hasDefault": false + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.LineLike", + "name": "BH.oM.FixtureAlpha.LineLike", + "pythonName": "LineLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.LineLike", + "relativePath": "FixtureAlpha_oM/LineLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.FixtureAlpha.ICurveLike", + "BH.oM.FixtureAlpha.ILabelled" + ], + "repo": "BHoM/CSharpSchema_Test", + "description": "A straight segment between two points.", + "properties": [ + { + "name": "Start", + "clrType": "BH.oM.FixtureAlpha.PointLike", + "pyName": "Start", + "hasDefault": true, + "default": " lambda: PointLike()" + }, + { + "name": "End", + "clrType": "BH.oM.FixtureAlpha.PointLike", + "pyName": "End", + "hasDefault": true, + "default": " lambda: PointLike()" + }, + { + "name": "Infinite", + "clrType": "bool", + "pyName": "Infinite", + "hasDefault": true, + "default": "False", + "description": "Extends the line to infinity." + }, + { + "name": "Label", + "clrType": "string", + "pyName": "Label", + "hasDefault": true, + "default": "None" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.Options", + "name": "BH.oM.FixtureAlpha.Options", + "pythonName": "Options", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Options", + "relativePath": "FixtureAlpha_oM/Options.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "enum", + "bases": [], + "repo": "BHoM/CSharpSchema_Test", + "flags": true, + "properties": [], + "members": [ + { + "name": "A", + "value": "1" + }, + { + "name": "B", + "value": "2" + }, + { + "name": "Both", + "value": "3" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.PointLike", + "name": "BH.oM.FixtureAlpha.PointLike", + "pythonName": "PointLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.PointLike", + "relativePath": "FixtureAlpha_oM/PointLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.FixtureAlpha.IGeometryLike" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "X", + "clrType": "double", + "pyName": "X", + "hasDefault": true, + "default": "0.0" + }, + { + "name": "Y", + "clrType": "double", + "pyName": "Y", + "hasDefault": true, + "default": "0.0" + }, + { + "name": "Z", + "clrType": "double", + "pyName": "Z", + "hasDefault": true, + "default": "0.0" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.ProjectLike", + "name": "BH.oM.FixtureAlpha.ProjectLike", + "pythonName": "ProjectLike", + "pythonModule": "bhom_schema.FixtureAlpha_oM.ProjectLike", + "relativePath": "FixtureAlpha_oM/ProjectLike.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.FixtureAlpha.ThingIdentity" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "ProjectReference", + "clrType": "string", + "pyName": "ProjectReference", + "hasDefault": true, + "default": "''" + }, + { + "name": "ProjectName", + "clrType": "string", + "pyName": "ProjectName", + "hasDefault": true, + "default": "''" + }, + { + "name": "ProjectLocation", + "clrType": "string", + "pyName": "ProjectLocation", + "hasDefault": true, + "default": "''" + }, + { + "name": "AdditionalReferences", + "clrType": "string", + "pyName": "AdditionalReferences", + "hasDefault": true, + "default": "''" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.Scalars", + "name": "BH.oM.FixtureAlpha.Scalars", + "pythonName": "Scalars", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Scalars", + "relativePath": "FixtureAlpha_oM/Scalars.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Created", + "clrType": "System.DateTime", + "pyName": "Created", + "hasDefault": true, + "default": " datetime.now" + }, + { + "name": "Fixed", + "clrType": "System.DateTime", + "pyName": "Fixed", + "hasDefault": true, + "default": "datetime(2020, 1, 2, 3, 4, 5, 0)" + }, + { + "name": "Key", + "clrType": "System.Guid", + "pyName": "Key", + "hasDefault": true, + "default": "UUID(int=0)" + }, + { + "name": "Cost", + "clrType": "decimal", + "pyName": "Cost", + "hasDefault": true, + "default": "Decimal('1.5')" + }, + { + "name": "Colour", + "clrType": "System.Drawing.Color", + "pyName": "Colour", + "hasDefault": true, + "default": " Color" + }, + { + "name": "Duration", + "clrType": "System.TimeSpan", + "pyName": "Duration", + "hasDefault": true, + "default": "timedelta(seconds=90.0)" + }, + { + "name": "Letter", + "clrType": "char", + "pyName": "Letter", + "hasDefault": true, + "default": "'x'" + }, + { + "name": "Quote", + "clrType": "string", + "pyName": "Quote", + "hasDefault": true, + "default": "'it\\'s \"here\"'" + }, + { + "name": "TypeRef", + "clrType": "System.Type", + "pyName": "TypeRef", + "hasDefault": true, + "default": "None" + }, + { + "name": "Callback", + "clrType": "System.Func", + "pyName": "Callback", + "hasDefault": true, + "default": "None" + }, + { + "name": "Big", + "clrType": "double", + "pyName": "Big", + "hasDefault": true, + "default": "1.7976931348623157e+308" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.Sparse", + "name": "BH.oM.FixtureAlpha.Sparse", + "pythonName": "Sparse", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Sparse", + "relativePath": "FixtureAlpha_oM/Sparse.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "enum", + "bases": [], + "repo": "BHoM/CSharpSchema_Test", + "properties": [], + "members": [ + { + "name": "One", + "value": "1" + }, + { + "name": "Two", + "value": "2" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.SparseHolder", + "name": "BH.oM.FixtureAlpha.SparseHolder", + "pythonName": "SparseHolder", + "pythonModule": "bhom_schema.FixtureAlpha_oM.SparseHolder", + "relativePath": "FixtureAlpha_oM/SparseHolder.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Level", + "clrType": "BH.oM.FixtureAlpha.Sparse", + "pyName": "Level", + "hasDefault": false + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.SparseParent", + "name": "BH.oM.FixtureAlpha.SparseParent", + "pythonName": "SparseParent", + "pythonModule": "bhom_schema.FixtureAlpha_oM.SparseParent", + "relativePath": "FixtureAlpha_oM/SparseParent.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "Holder", + "clrType": "BH.oM.FixtureAlpha.SparseHolder", + "pyName": "Holder", + "hasDefault": false + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.SupportKind", + "name": "BH.oM.FixtureAlpha.SupportKind", + "pythonName": "SupportKind", + "pythonModule": "bhom_schema.FixtureAlpha_oM.SupportKind", + "relativePath": "FixtureAlpha_oM/SupportKind.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "enum", + "bases": [], + "repo": "BHoM/CSharpSchema_Test", + "description": "Kinds of support.", + "properties": [], + "members": [ + { + "name": "None", + "value": "0" + }, + { + "name": "Fixed", + "value": "1", + "description": "Fully fixed." + }, + { + "name": "Pinned", + "value": "2" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.ThingIdentity", + "name": "BH.oM.FixtureAlpha.ThingIdentity", + "pythonName": "ThingIdentity", + "pythonModule": "bhom_schema.FixtureAlpha_oM.ThingIdentity", + "relativePath": "FixtureAlpha_oM/ThingIdentity.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "interface", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [] + }, + { + "clrName": "BH.oM.FixtureAlpha.TreeLike`1", + "name": "BH.oM.FixtureAlpha.TreeLike", + "pythonName": "TreeLike_1", + "pythonModule": "bhom_schema.FixtureAlpha_oM.TreeLike_1", + "relativePath": "FixtureAlpha_oM/TreeLike_1.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "genericParams": [ + { + "name": "T", + "constraints": [] + } + ], + "properties": [ + { + "name": "Children", + "clrType": "System.Collections.Generic.Dictionary>", + "pyName": "Children", + "hasDefault": true, + "default": " lambda: {}" + }, + { + "name": "Values", + "clrType": "System.Collections.Generic.List", + "pyName": "Values", + "hasDefault": true, + "default": " lambda: []" + } + ] + }, + { + "clrName": "BH.oM.FixtureAlpha.Wrapper`1", + "name": "BH.oM.FixtureAlpha.Wrapper", + "pythonName": "Wrapper_1", + "pythonModule": "bhom_schema.FixtureAlpha_oM.Wrapper_1", + "relativePath": "FixtureAlpha_oM/Wrapper_1.py", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "genericParams": [ + { + "name": "T", + "constraints": [ + "BH.oM.Base.IBHoMObject" + ] + } + ], + "properties": [ + { + "name": "Items", + "clrType": "System.Collections.Generic.List", + "pyName": "Items", + "hasDefault": true, + "default": " lambda: []" + } + ] + }, + { + "clrName": "BH.oM.FixtureBeta.Elements.UsesBoth", + "name": "BH.oM.FixtureBeta.Elements.UsesBoth", + "pythonName": "UsesBoth", + "pythonModule": "bhom_schema.FixtureBeta_oM.Elements.UsesBoth", + "relativePath": "FixtureBeta_oM/Elements/UsesBoth.py", + "namespace": "BH.oM.FixtureBeta.Elements", + "assembly": "FixtureBeta_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.BHoMObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "FromAlpha", + "clrType": "BH.oM.FixtureAlpha.PointLike", + "pyName": "FromAlpha", + "hasDefault": true, + "default": "None" + }, + { + "name": "Local", + "clrType": "BH.oM.FixtureBeta.PointLike", + "pyName": "Local", + "hasDefault": true, + "default": "None" + } + ] + }, + { + "clrName": "BH.oM.FixtureBeta.PointLike", + "name": "BH.oM.FixtureBeta.PointLike", + "pythonName": "PointLike", + "pythonModule": "bhom_schema.FixtureBeta_oM.PointLike", + "relativePath": "FixtureBeta_oM/PointLike.py", + "namespace": "BH.oM.FixtureBeta", + "assembly": "FixtureBeta_oM", + "kind": "class", + "bases": [ + "BH.oM.Base.IObject" + ], + "repo": "BHoM/CSharpSchema_Test", + "properties": [ + { + "name": "X", + "clrType": "double", + "pyName": "X", + "hasDefault": true, + "default": "0.0" + } + ] + } + ] +} \ No newline at end of file diff --git a/.ci/unit-tests/golden/patcher/Bar.cs b/.ci/unit-tests/golden/patcher/Bar.cs new file mode 100644 index 0000000..a20cab4 --- /dev/null +++ b/.ci/unit-tests/golden/patcher/Bar.cs @@ -0,0 +1,87 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + + +using System.ComponentModel; +using BH.oM.Base; +using BH.oM.Dimensional; +using BH.oM.Geometry; +using BH.oM.Structure.SectionProperties; +using BH.oM.Structure.Constraints; +using BH.oM.Structure.Offsets; +using BH.oM.Analytical.Elements; +using BH.oM.Quantities.Attributes; +using BH.oM.Base.Attributes; +using BH.oM.Base.Attributes.Enums; + +namespace BH.oM.Structure.Elements +{ + + [Description("1D finite element for structural analysis. Linear 2-noded element defined by a start and end node." + + "For structural conventions and orientation of the bar please see the documentation.")] + [DocumentationURL("https://bhom.xyz/documentation/BHoM_oM/Structure_oM/BHoM-Structural-Conventions/", DocumentationType.Documentation)] + public class Bar : BHoMObject, IElement1D, IElementM, ILink + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + [Description("Defines the start position of the element. Note that Nodes can contain Supports which should not be confused with Releases.")] + public virtual Node Start { get; set; } + + [Description("Defines the end position of the element. Note that Nodes can contain Supports which should not be confused with Releases.")] + public virtual Node End { get; set; } + + [Description("Section property of the bar, containing all sectional constants and material as well as profile geometry and dimensions, where applicable.")] + public virtual ISectionProperty SectionProperty { get; set; } = null; + + [Angle] + [Description("Controls the local axis orientation of the bar \n" + + "For non-vertical members the local z is aligned with the global Z and rotated with the orientation angle about the local x. \n" + + "For vertical members the local y is aligned with the global Y and rotated with the orientation angle about the local x. \n"+ + "A bar is vertical if its projected length to the horizontal plane is less than 0.0001, i.e. a tolerance of 0.1mm on verticality. \n" + + "For general structural conventions please see the documentation.")] + [DocumentationURL("https://bhom.xyz/documentation/BHoM_oM/Structure_oM/BHoM-Structural-Conventions/", DocumentationType.Documentation)] + public virtual double OrientationAngle { get; set; } = 0; + + [Description("Defines the start and end release of the Bar. The releases defines how the bar is attached to its end nodes. If not set, full fixity will be assumed.")] + public virtual BarRelease Release { get; set; } = null; + + public virtual BarFEAType FEAType { get; set; } = BarFEAType.Flexural; + + [Description("Linear support for the bar. Three translational degrees of freedom and one rotational. The rotational DOF defines constraint about the axis of the bar.")] + public virtual Constraint4DOF Support { get; set; } = null; + + [Description("Offset of the bar as two vectors, one per end node, in bar local coordinates. Defines offsets from centreline to be applied in analysis packages.")] + public virtual Offset Offset { get; set; } = null; + + + /***************************************************/ + } +} + + + + + + + diff --git a/SoftwareName_Adapter/Convert/ToSoftwareName/ExampleObject.cs b/.ci/unit-tests/golden/patcher/ProjectIdentification.cs similarity index 74% rename from SoftwareName_Adapter/Convert/ToSoftwareName/ExampleObject.cs rename to .ci/unit-tests/golden/patcher/ProjectIdentification.cs index 60359b4..39394a1 100644 --- a/SoftwareName_Adapter/Convert/ToSoftwareName/ExampleObject.cs +++ b/.ci/unit-tests/golden/patcher/ProjectIdentification.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. * * Each contributor holds copyright over their respective contributions. * The project versioning (Git) records all such contribution source information. @@ -20,32 +20,27 @@ * along with this code. If not, see . */ -using BH.oM.Adapters.SoftwareName; using BH.oM.Base; using System; using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; -namespace BH.Adapter.SoftwareName +namespace BH.oM.dKoP { - public static partial class Convert + public class ProjectIdentification : IdKoPObject { /***************************************************/ - /**** Public Methods ****/ + /**** Properties ****/ /***************************************************/ - // Add methods for converting from BHoM to the specific software types - // Example: - public static ExampleObject ToSoftwareName(this BHoMObject node) - { - //Insert code for convertion - throw new NotImplementedException(); - } + public virtual string ProjectReference { get; set; } = ""; + + public virtual string ProjectName { get; set; } = ""; + + public virtual string ProjectLocation { get; set; } = ""; + + public virtual string AdditionalReferences { get; set; } = ""; /***************************************************/ } } - - diff --git a/.ci/unit-tests/golden/py2cs/Box.cs b/.ci/unit-tests/golden/py2cs/Box.cs new file mode 100644 index 0000000..6d1f3a0 --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/Box.cs @@ -0,0 +1,39 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using System.Collections.Generic; + +namespace BH.oM.Demo +{ + public class Box : ILabelled where T : ILabelled + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + public virtual List Items { get; set; } = new List(); + + public virtual string Label { get; set; } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/golden/py2cs/Everything.cs b/.ci/unit-tests/golden/py2cs/Everything.cs new file mode 100644 index 0000000..f090d7d --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/Everything.cs @@ -0,0 +1,93 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using BH.oM.Base.Attributes; +using BH.oM.Quantities.Attributes; +using System; +using System.Collections.Generic; +using System.ComponentModel; + +namespace BH.oM.Demo +{ + [Description("Has every kind of property.")] + public class Everything : IdentityBase + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + public virtual string Required { get; } + + public virtual string Text { get; set; } = ""; + + public virtual string Quoted { get; set; } = "say \"hi\""; + + public virtual long Count { get; set; } = 3; + + public virtual float Small { get; set; } = 0.5f; + + public virtual double Ratio { get; set; } = 1.5; + + [Description("A flag.")] + public virtual bool Flag { get; set; } = false; + + public virtual Mode Choice { get; set; } = Mode.None; + + public virtual Mode Other { get; set; } = Mode.Fast; + + public virtual HashSet Names { get; set; } = new HashSet(); + + public virtual Dictionary Lookup { get; set; } = new Dictionary(); + + public virtual double[] Values { get; set; } = new double[0]; + + public virtual IEnumerable Words { get; set; } = new List { "a", "b" }; + + public virtual int? Maybe { get; set; } + + public virtual Guid Key { get; set; } = Guid.Empty; + + public virtual Guid Fresh { get; set; } = Guid.NewGuid(); + + public virtual decimal Cost { get; set; } = 1.5m; + + public virtual DateTime Fixed { get; set; } = new DateTime(2020, 1, 2, 3, 4, 5); + + public virtual DateTime Created { get; set; } = DateTime.Now; + + [Description("Distance between supports.")] + [Length] + [DisplayText("Span length")] + public virtual double Span { get; set; } = 1.5; + + /***************************************************/ + /**** Constructors ****/ + /***************************************************/ + + public Everything(string required) + { + Required = required; + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/golden/py2cs/Fixed.cs b/.ci/unit-tests/golden/py2cs/Fixed.cs new file mode 100644 index 0000000..d99dbe2 --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/Fixed.cs @@ -0,0 +1,50 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +namespace BH.oM.Demo +{ + public class Fixed + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + public virtual string Id { get; } + + public virtual int @class { get; } + + public virtual double Length { get; } + + /***************************************************/ + /**** Constructors ****/ + /***************************************************/ + + public Fixed(string id, int @class, double length) + { + Id = id; + @class = @class; + Length = length; + } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/golden/py2cs/Flags.cs b/.ci/unit-tests/golden/py2cs/Flags.cs new file mode 100644 index 0000000..204674e --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/Flags.cs @@ -0,0 +1,34 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using System; + +namespace BH.oM.Demo +{ + [Flags] + public enum Flags + { + A = 1, + B = 2, + Both = 3 + } +} diff --git a/.ci/unit-tests/golden/py2cs/ILabelled.cs b/.ci/unit-tests/golden/py2cs/ILabelled.cs new file mode 100644 index 0000000..a5d964d --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/ILabelled.cs @@ -0,0 +1,38 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using System.ComponentModel; + +namespace BH.oM.Demo +{ + public interface ILabelled + { + /***************************************************/ + /**** Properties ****/ + /***************************************************/ + + [Description("The label.")] + string Label { get; set; } + + /***************************************************/ + } +} diff --git a/.ci/unit-tests/golden/py2cs/Mode.cs b/.ci/unit-tests/golden/py2cs/Mode.cs new file mode 100644 index 0000000..eccee94 --- /dev/null +++ b/.ci/unit-tests/golden/py2cs/Mode.cs @@ -0,0 +1,34 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using System.ComponentModel; + +namespace BH.oM.Demo +{ + [Description("Speeds.")] + public enum Mode + { + None, + [Description("Quick.")] + Fast + } +} diff --git a/.ci/unit-tests/golden/spike/Graphics_oM/Enums/GradientCenteringOptions.py b/.ci/unit-tests/golden/spike/Graphics_oM/Enums/GradientCenteringOptions.py new file mode 100644 index 0000000..314437a --- /dev/null +++ b/.ci/unit-tests/golden/spike/Graphics_oM/Enums/GradientCenteringOptions.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Graphics.Enums.GradientCenteringOptions. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMEnum + + +class GradientCenteringOptions(BHoMEnum): + """Either do not centre the range of the gradient, extend the range to be centred around 0, or make 0 the midpoint of the gradient while keeping UpperBound and LowerBound as the ends.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Graphics.Enums.GradientCenteringOptions', + 'assembly': 'Graphics_oM', + 'namespace': 'BH.oM.Graphics.Enums', + 'repo': 'BHoM/BHoM', + 'kind': 'enum', + 'renamed_members': {'None_': 'None'}, + } + + # No change to the gradient range. + None_ = 0 + # Make 0 the midpoint of the gradient while keeping UpperBound and LowerBound as the ends. + Asymmetric = 1 + # Extend range to be centred around 0. + Symmetric = 2 diff --git a/.ci/unit-tests/python/support.py b/.ci/unit-tests/python/support.py new file mode 100644 index 0000000..b97d4f9 --- /dev/null +++ b/.ci/unit-tests/python/support.py @@ -0,0 +1,71 @@ +"""Helpers shared by the pytest suites that import generated packages.""" + +from __future__ import annotations + +import importlib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[3] +OUTPUT = REPO / ".ci" / "unit-tests" / "_output" + + +def require_output(name: str) -> Path: + """Returns the folder of the generated `bhom_schema` package that the NUnit run wrote for a named set ('fixtures' or 'spike'), or fails the test in CI and skips it locally when it is missing. + + The pytest suites for the generated package and the alignment checks call it first, since they all depend on the generator's output. + """ + package = OUTPUT / name / "bhom_schema" + if (package / "__init__.py").exists(): + return package + message = f"{package} not found: run `dotnet test` on the solution first (needs the BHoM assemblies for 'spike')" + if os.environ.get("CI", "").lower() == "true": + pytest.fail(message) + pytest.skip(message) + + +def attach(package: Path) -> None: + """Makes `bhom_schema....` imports resolve to a generated package folder, ahead of the committed classes in `src`. + + The conftest fixtures of `test_fixtures` and `test_spike` call it so the tests import what the generator just wrote, not the committed package. + """ + import bhom_schema + + path = str(package) + if path in bhom_schema.__path__: + bhom_schema.__path__.remove(path) + + # First, so a test package is not shadowed by a same-named folder of the committed package (for example BHoM) + bhom_schema.__path__.insert(0, path) + importlib.invalidate_caches() + + +def module_names(package: Path) -> list[str]: + """Lists the dotted module names of every generated class file in a package, skipping `__init__` and `_version` files and the `_support` folder. + + The fixture and spike suites use it to import every generated module and rebuild its model. + """ + names: list[str] = [] + for file in sorted(package.rglob("*.py")): + relative = file.relative_to(package.parent).with_suffix("") + if relative.parts[-1] in ("__init__", "_version") or relative.parts[1] == "_support": + continue + names.append(".".join(relative.parts)) + return names + + +def run_pyright(root: Path, include: str = "bhom_schema") -> subprocess.CompletedProcess[str]: + """Writes a strict `pyrightconfig.json` in a generated package's root and runs pyright over it, returning the completed process. + + The fixture and spike suites use it to check that the generator's output type-checks cleanly. + """ + config = root / "pyrightconfig.json" + config.write_text('{ "include": ["%s"], "typeCheckingMode": "strict", "pythonVersion": "3.12", "extraPaths": ["."] }' % include, encoding="utf-8") + return subprocess.run( + [sys.executable, "-m", "pyright", "--pythonpath", sys.executable], + cwd=root, capture_output=True, text=True, timeout=600, + ) diff --git a/.ci/unit-tests/python/test_alignment/test_align_with_generator.py b/.ci/unit-tests/python/test_alignment/test_align_with_generator.py new file mode 100644 index 0000000..3e2dade --- /dev/null +++ b/.ci/unit-tests/python/test_alignment/test_align_with_generator.py @@ -0,0 +1,95 @@ +"""The alignment check end to end: the real generator runs over the built fixture assemblies, as it will over the merged C#.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + +from py2cs.__main__ import main +from py2cs.align import BUILD_HINT, default_generator +from support import REPO, require_output + +BEAM = "FixtureAlpha_oM/Beam.py" +TEST_BUILD = REPO / ".ci" / "unit-tests" / "csharp" / "PythonSchemaGeneration.Tests" / "bin" / "Release" / "net8.0" + +@pytest.fixture +def assemblies(tmp_path: Path) -> Path: + """Fixture that copies the fixture oM assemblies and `BHoM.dll` from the test build into a temporary folder and returns it, or fails in CI and skips locally if the generator or build is missing. + + The `py2cs align` tests pass it as `--assemblies`, like the folder of a build of the merged C#. + """ + sources = [TEST_BUILD / name for name in ("FixtureAlpha_oM.dll", "FixtureBeta_oM.dll", "BHoM.dll")] + if not default_generator().exists() or not all(s.exists() for s in sources): + message = f"the generator or the test build is missing; {BUILD_HINT}" + if os.environ.get("CI", "").lower() == "true": + pytest.fail(message) + pytest.skip(message) + + folder = tmp_path / "dlls" + folder.mkdir() + for source in sources: + shutil.copy(source, folder) + return folder + +def copy_fixtures(destination: Path) -> Path: + """Copies the generated fixtures package into a `bhom_schema` folder under the given folder and returns it. + + Builds the base and head package roots that the `py2cs align` tests compare. + """ + shutil.copytree(require_output("fixtures"), destination / "bhom_schema") + return destination / "bhom_schema" + +def edit(package: Path, old: str, new: str) -> None: + """Replaces a text in the generated `Beam.py` of a package, asserting that the text exists, and writes the file back. + + Lets the align tests make a Python PR that differs from the C#. + """ + path = package / BEAM + text = path.read_text(encoding="utf-8") + assert old in text + path.write_text(text.replace(old, new), encoding="utf-8", newline="\n") + +def test_a_pr_that_matches_the_csharp_passes(tmp_path: Path, assemblies: Path, capsys: pytest.CaptureFixture[str]): + """Checks that `py2cs align` exits 0 and reports no differences when the PR adds a property exactly as the generator writes it from the C#. + + Protects the pull request check that lets a correct Python change through. + """ + base = copy_fixtures(tmp_path / "base") + text = (base / BEAM).read_text(encoding="utf-8") + (base / BEAM).write_text("".join(line for line in text.splitlines(keepends=True) if not line.startswith(" Small:")), encoding="utf-8", newline="\n") + head = copy_fixtures(tmp_path / "head") + + status = main(["align", "--base-root", str(base), "--head-root", str(head), "--assemblies", str(assemblies)]) + assert status == 0, capsys.readouterr().out + assert "1 classes compared, 0 differences" in capsys.readouterr().out + +def test_a_pr_that_differs_from_the_csharp_lists_the_difference(tmp_path: Path, assemblies: Path, capsys: pytest.CaptureFixture[str]): + """Checks that `py2cs align` exits 1 and names the property and default value that differ when the PR changes a default from the C#. + + Protects the pull request check that stops Python that diverges from the merged C#. + """ + base = copy_fixtures(tmp_path / "base") + head = copy_fixtures(tmp_path / "head") + edit(head, "Count: Annotated[int, ClrType('long')] = 3", "Count: Annotated[int, ClrType('long')] = 4") + + status = main(["align", "--base-root", str(base), "--head-root", str(head), "--assemblies", str(assemblies)]) + assert status == 1 + assert "difference: BH.oM.FixtureAlpha.Beam.Count: default differs: '3' != '4'" in capsys.readouterr().out + +def test_a_class_the_csharp_does_not_have_fails(tmp_path: Path, assemblies: Path, capsys: pytest.CaptureFixture[str]): + """Checks that `py2cs align` exits 1 and reports a class missing from the C# when the PR adds a class the C# does not have. + + Protects the pull request check that stops Python classes whose C# was not merged. + """ + base = copy_fixtures(tmp_path / "base") + head = copy_fixtures(tmp_path / "head") + (head / "FixtureAlpha_oM" / "Proposed.py").write_text( + (head / "FixtureAlpha_oM" / "Beam.py").read_text(encoding="utf-8").replace("class Beam(", "class Proposed(").replace("BH.oM.FixtureAlpha.Beam", "BH.oM.FixtureAlpha.Proposed"), + encoding="utf-8", newline="\n") + + status = main(["align", "--base-root", str(base), "--head-root", str(head), "--assemblies", str(assemblies)]) + assert status == 1 + assert "BH.oM.FixtureAlpha.Proposed: not found in the C#" in capsys.readouterr().out diff --git a/.ci/unit-tests/python/test_alignment/test_manifest_alignment.py b/.ci/unit-tests/python/test_alignment/test_manifest_alignment.py new file mode 100644 index 0000000..fe6b1dc --- /dev/null +++ b/.ci/unit-tests/python/test_alignment/test_manifest_alignment.py @@ -0,0 +1,45 @@ +"""The structural check: what the C# reflection says must equal what is read back from the generated Python.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from py2cs.compare import compare_manifests +from py2cs.model import load_manifest +from py2cs.reader import read_package +from support import OUTPUT, require_output + + +@pytest.mark.parametrize("name", ["fixtures", "spike"]) +def test_python_reads_back_exactly_what_csharp_reflection_reported(name: str): + """Checks that reading the generated Python without running it gives the same manifest the generator wrote from C# reflection, for the fixtures and the spike set. + + Protects the py2cs reader and the generator against drifting apart, which the diff and align stages depend on. + """ + package = require_output(name) + expected = load_manifest(OUTPUT / name / "manifest.json") + actual = read_package(package) + problems = compare_manifests(expected, actual, allow_extra=False) + assert problems == [], "\n".join(problems[:30]) + assert len(expected) > 20 + + +def test_every_generated_class_names_a_resolvable_repo(): + """Checks that every generated class resolves to a C# repository, using a small listing for the fixtures and the real repository listing for the spike types. + + Protects the provenance data that py2cs `resolve` uses to pick where a draft pull request goes. + """ + from py2cs.resolve import load_listing, resolve_repo + + package = require_output("fixtures") + manifest = read_package(package) + listing = {"BHoM/CSharpSchema_Test": ["FixtureAlpha_oM", "FixtureBeta_oM"], "BHoM/BHoM": ["BHoM"]} + for info in manifest.values(): + resolve_repo(info, listing) + + spike = read_package(require_output("spike")) + real = load_listing(Path(__file__).resolve().parents[2] / "github-fixtures" / "repo-listing.json") + for info in spike.values(): + assert resolve_repo(info, real).repo.startswith("BHoM/"), info.clr_name diff --git a/.ci/unit-tests/python/test_alignment/test_roundtrip_alignment.py b/.ci/unit-tests/python/test_alignment/test_roundtrip_alignment.py new file mode 100644 index 0000000..2eb95e8 --- /dev/null +++ b/.ci/unit-tests/python/test_alignment/test_roundtrip_alignment.py @@ -0,0 +1,27 @@ +"""Python to C# round trip: the draft C# (compiled and reflected by the NUnit RoundTrip tests) must match the original types.""" + +from __future__ import annotations + +import pytest + +from py2cs.compare import compare_manifests +from py2cs.model import load_manifest +from support import OUTPUT, require_output + +# The drafts are one assembly with a different name, so only the shape of each type is compared +IGNORED = frozenset({"assembly", "repo", "path"}) + + +@pytest.mark.parametrize("name", ["fixtures", "spike"]) +def test_drafted_csharp_reflects_back_to_the_original_types(name: str): + """Checks that the C# drafted from the generated Python, once compiled and reflected, gives a manifest equal in shape to the original, ignoring assembly, repo and path. + + Protects the Python to C# direction (`csharp` stage) from losing information; it skips if the NUnit round-trip output is missing. + """ + require_output(name) + draft = OUTPUT / "roundtrip" / name / "draft-manifest.json" + if not draft.exists(): + pytest.skip(f"{draft} not found: run the RoundTrip NUnit tests first") + original = load_manifest(OUTPUT / name / "manifest.json") + problems = compare_manifests(original, load_manifest(draft), allow_extra=False, ignore=IGNORED) + assert problems == [], "\n".join(problems[:40]) diff --git a/.ci/unit-tests/python/test_alignment/test_verify.py b/.ci/unit-tests/python/test_alignment/test_verify.py new file mode 100644 index 0000000..7f9bb03 --- /dev/null +++ b/.ci/unit-tests/python/test_alignment/test_verify.py @@ -0,0 +1,51 @@ +"""`py2cs verify`: the check the weekly generation runs on its output before committing it.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from py2cs.__main__ import main +from py2cs.ci import verify_package +from support import OUTPUT, require_output + +def test_the_generated_fixtures_pass(capsys: pytest.CaptureFixture[str]): + """Checks that `verify_package` and the `py2cs verify` command report no problems for the generated fixtures package against its C# manifest. + + Protects the check the weekly generation runs on its output before committing it. + """ + package = require_output("fixtures") + count, problems = verify_package(package, OUTPUT / "fixtures" / "manifest.json") + assert problems == [] and count > 20 + + assert main(["verify", "--package", str(package), "--manifest", str(OUTPUT / "fixtures" / "manifest.json")]) == 0 + assert "0 problems" in capsys.readouterr().out + +def test_a_module_that_does_not_import_fails(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that a generated module that fails to import is reported as an import failure and makes `py2cs verify` exit 1. + + Protects the weekly generation from committing a package that cannot be imported. + """ + package = tmp_path / "bhom_schema" + shutil.copytree(require_output("fixtures"), package) + (package / "FixtureAlpha_oM" / "Broken.py").write_text("from bhom_schema.Nowhere import Missing\n", encoding="utf-8") + + _, problems = verify_package(package) + assert any(p.startswith("import failed: bhom_schema.FixtureAlpha_oM.Broken: ModuleNotFoundError") for p in problems) + + assert main(["verify", "--package", str(package)]) == 1 + assert "error: import failed" in capsys.readouterr().out + +def test_a_package_that_differs_from_the_csharp_manifest_fails(tmp_path: Path): + """Checks that deleting a generated class file makes `verify_package` report that class as missing against the C# manifest. + + Protects the weekly generation from committing a package that lacks classes the C# has. + """ + package = tmp_path / "bhom_schema" + shutil.copytree(require_output("fixtures"), package) + (package / "FixtureAlpha_oM" / "Beam.py").unlink() + + _, problems = verify_package(package, OUTPUT / "fixtures" / "manifest.json") + assert "BH.oM.FixtureAlpha.Beam: missing" in problems diff --git a/.ci/unit-tests/python/test_fixtures/conftest.py b/.ci/unit-tests/python/test_fixtures/conftest.py new file mode 100644 index 0000000..5232b47 --- /dev/null +++ b/.ci/unit-tests/python/test_fixtures/conftest.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + +from support import attach, require_output + + +@pytest.fixture(scope="session", autouse=True) +def fixtures_package(): + """Session fixture, applied automatically to the fixture suite: locates the generated 'fixtures' package, attaches it so imports resolve to it, and returns its folder. + + Every test in `test_fixtures` relies on it to import the generator's output for the fixture assemblies. + """ + package = require_output("fixtures") + attach(package) + return package diff --git a/.ci/unit-tests/python/test_fixtures/test_generated_fixtures.py b/.ci/unit-tests/python/test_fixtures/test_generated_fixtures.py new file mode 100644 index 0000000..f121ee6 --- /dev/null +++ b/.ci/unit-tests/python/test_fixtures/test_generated_fixtures.py @@ -0,0 +1,332 @@ +"""Behaviour of the Python generated from the fixture assemblies (one test per gotcha in the research notes).""" + +from __future__ import annotations + +import importlib +from datetime import datetime +from decimal import Decimal +from typing import Any, cast +from uuid import UUID + +import pytest +from pydantic import BaseModel, ValidationError + +from support import module_names, run_pyright + + +def load(module: str) -> Any: + """Imports a module by its dotted name and returns it. + + Helper of the fixture tests that look at generated modules. + """ + return importlib.import_module(module) + + +def cls(path: str) -> Any: + """Returns the generated class at a path such as `FixtureAlpha_oM.Beam`, by importing `bhom_schema.` and taking the class named after the last segment. + + Helper used by most fixture tests to get a generated type. + """ + module = load(f"bhom_schema.{path}") + return getattr(module, path.rsplit(".", 1)[-1]) + + +def test_every_module_imports_and_every_model_rebuilds(fixtures_package): + """Checks that every module generated from the fixture assemblies imports and that every Pydantic model in them rebuilds without error. + + Protects the generator's emitted Python (imports, forward references, cycles) from being syntactically or structurally broken. + """ + names = module_names(fixtures_package) + assert len(names) > 20 + for name in names: + module = load(name) + generated = getattr(module, name.rsplit(".", 1)[-1]) + if isinstance(generated, type) and issubclass(generated, BaseModel): + assert generated.model_rebuild(force=True) in (True, None), name + + +def test_pyright_strict_is_clean(fixtures_package): + """Checks that pyright in strict mode reports no problems on the Python generated from the fixture assemblies. + + Protects the generator's type expressions and imports, so the committed schema stays type-clean. + """ + result = run_pyright(fixtures_package.parent) + assert result.returncode == 0, result.stdout + result.stderr + + +# ---------------------------------------------------------------- interfaces and MRO + +def test_interface_chain_has_a_consistent_mro(): + """Checks that interface-only fixture types (curve, geometry, element) and a class implementing them form a valid inheritance chain. + + Protects the generator's base ordering, which Python needs for a consistent method resolution order. + """ + ICurveLike = cls("FixtureAlpha_oM.ICurveLike") + IGeometryLike = cls("FixtureAlpha_oM.IGeometryLike") + IElement1DLike = cls("FixtureAlpha_oM.IElement1DLike") + LineLike = cls("FixtureAlpha_oM.LineLike") + assert issubclass(ICurveLike, IGeometryLike) and issubclass(ICurveLike, IElement1DLike) + assert issubclass(LineLike, ICurveLike) and issubclass(LineLike, IGeometryLike) + + +def test_interface_typed_field_accepts_implementers_and_keeps_the_subclass(): + """Checks that a field typed as an interface accepts any generated implementer, singly or in a list, and keeps the actual subclass instead of coercing it. + + Protects how the generator maps C# interface types to Python annotations. + """ + GeometryHolder = cls("FixtureAlpha_oM.GeometryHolder") + LineLike = cls("FixtureAlpha_oM.LineLike") + PointLike = cls("FixtureAlpha_oM.PointLike") + holder = GeometryHolder(Geometry=PointLike(X=2.0)) + assert type(holder.Geometry) is PointLike + holder = GeometryHolder(Geometry=LineLike(), Curves=[LineLike()]) + assert type(holder.Geometry) is LineLike + assert type(holder.Curves[0]) is LineLike + + +def test_interface_typed_field_rejects_non_implementers(): + """Checks that an interface-typed field raises a validation error for a number, for an unrelated class and for a list item that does not implement the interface. + + Protects the generated annotations from being too loose, which would let invalid objects into the schema. + """ + GeometryHolder = cls("FixtureAlpha_oM.GeometryHolder") + ProjectLike = cls("FixtureAlpha_oM.ProjectLike") + with pytest.raises(ValidationError): + GeometryHolder(Geometry=3) + with pytest.raises(ValidationError): + GeometryHolder(Geometry=ProjectLike()) + with pytest.raises(ValidationError): + GeometryHolder(Curves=[cls("FixtureAlpha_oM.PointLike")()]) + + +# ---------------------------------------------------------------- the ProjectIdentification twin + +def test_project_like_is_a_flat_interface_only_class(): + """Checks that a class implementing only interfaces has its defaults and field order as the C# declares, derives from its interface twin and not from `BHoMObject`. + + Protects how the generator handles types with no base class and no inherited properties. + """ + ProjectLike = cls("FixtureAlpha_oM.ProjectLike") + ThingIdentity = cls("FixtureAlpha_oM.ThingIdentity") + BHoMObject = cls("BHoM.BHoMObject") + project = ProjectLike() + assert (project.ProjectReference, project.ProjectName, project.ProjectLocation, project.AdditionalReferences) == ("", "", "", "") + assert isinstance(project, ThingIdentity) + assert not isinstance(project, BHoMObject) + # bhom_version is the field of SchemaModel that holds `_bhomVersion` + assert list(ProjectLike.model_fields) == ["bhom_version", "ProjectReference", "ProjectName", "ProjectLocation", "AdditionalReferences"] + + +def test_project_like_lives_at_the_namespace_path_not_the_source_folder(): + """Checks that a generated class sits in the module path of its assembly and carries the expected `__bhom__` provenance (CLR name, namespace, assembly, repo, kind). + + Protects the metadata that py2cs later uses to find the owning C# repository. + """ + module = load("bhom_schema.FixtureAlpha_oM.ProjectLike") + assert module.ProjectLike.__bhom__ == { + "clr_name": "BH.oM.FixtureAlpha.ProjectLike", + "namespace": "BH.oM.FixtureAlpha", + "assembly": "FixtureAlpha_oM", + "repo": "BHoM/CSharpSchema_Test", + "kind": "class", + } + + +# ---------------------------------------------------------------- defaults and required fields + +def test_required_fields_after_defaulted_base_fields(): + """Checks that a class with required properties validates only when they are given, keeps the base class defaults and generates a fresh GUID. + + Protects the generator's defaults and required-field handling when a base has defaulted fields. + """ + ImmutableThing = cls("FixtureAlpha_oM.ImmutableThing") + with pytest.raises(ValidationError): + ImmutableThing() + thing = ImmutableThing(Id="a", Length=2.0) + assert thing.Name == "" and thing.Tags == set() + assert isinstance(thing.BHoM_Guid, UUID) + + +def test_mutable_defaults_are_not_shared(): + """Checks that list, set and matrix defaults are new objects per instance and that a literal list default keeps its values. + + Protects the generator's default formatting from sharing mutable state between instances. + """ + Collections = cls("FixtureAlpha_oM.Collections") + a, b = Collections(), Collections() + a.Names.add("x") + a.Matrix.append([1]) + assert b.Names == set() and b.Matrix == [] + assert a.Words == ["a", "b"] + + +def test_guid_and_datetime_defaults_are_fresh_per_instance(): + """Checks that GUID and creation-time defaults are produced per instance, and that fixed datetime, decimal, colour and quoted-string defaults come out as the C# declares. + + Protects the generator's literal and default-factory emission. + """ + Scalars = cls("FixtureAlpha_oM.Scalars") + BHoMObject = cls("BHoM.BHoMObject") + assert BHoMObject().BHoM_Guid != BHoMObject().BHoM_Guid + first, second = Scalars(), Scalars() + assert isinstance(first.Created, datetime) and first.Created <= second.Created + assert first.Fixed == datetime(2020, 1, 2, 3, 4, 5) + assert first.Cost == Decimal("1.5") + assert first.Colour.A == 0 and first.Quote == 'it\'s "here"' + + +def test_object_default_is_a_new_instance_each_time(): + """Checks that an object-typed default (a nested class) is a new instance for each parent and that a nullable object defaults to None. + + Protects the generator's default-instance reading and emission. + """ + LineLike = cls("FixtureAlpha_oM.LineLike") + a, b = LineLike(), LineLike() + assert a.Start is not b.Start + assert a.Label is None + + +# ---------------------------------------------------------------- enums + +def test_enum_member_named_none_is_renamed_and_recorded(): + """Checks that an enum member named `None` is generated as `None_`, that the original name is recorded in `__bhom__`, and that a property defaulting to the enum resolves to the renamed member. + + Protects the naming rules that keep generated names valid Python while staying traceable to the C#. + """ + SupportKind = cls("FixtureAlpha_oM.SupportKind") + assert [m.name for m in SupportKind] == ["None_", "Fixed", "Pinned"] + assert SupportKind.__bhom__["renamed_members"] == {"None_": "None"} + Beam = cls("FixtureAlpha_oM.Beam") + assert Beam().Support is SupportKind.Pinned + + +def test_flags_enum_supports_combinations(): + """Checks that a generated flags enum supports bitwise combination, with a combined member equal to the OR of its parts. + + Protects how the generator emits `[Flags]` enums. + """ + Options = cls("FixtureAlpha_oM.Options") + assert Options.A | Options.B == Options.Both + + +# ---------------------------------------------------------------- generics + +def test_bounded_generic_accepts_the_bound_only(): + """Checks that a generic class with a constraint records its CLR name and bounds, accepts a parameterised list of the bound type and rejects invalid items. + + Protects the generator's mapping of C# generics and their constraints. + """ + Wrapper_1 = cls("FixtureAlpha_oM.Wrapper_1") + Beam = cls("FixtureAlpha_oM.Beam") + assert Wrapper_1.__bhom__["clr_name"] == "BH.oM.FixtureAlpha.Wrapper`1" + assert Wrapper_1.__bhom__["generic_params"] == {"T": ["BH.oM.Base.IBHoMObject"]} + group = Wrapper_1[Beam](Items=[Beam()]) + assert len(group.Items) == 1 + with pytest.raises(ValidationError): + Wrapper_1[Beam](Items=[3]) + + +# ---------------------------------------------------------------- same names, cycles, collections + +def test_same_named_classes_import_together(): + """Checks that classes with the same name in two fixture assemblies are distinct and can be used together in one class without confusion. + + Protects the generator's name tracking and import aliasing across assemblies. + """ + UsesBoth = cls("FixtureBeta_oM.Elements.UsesBoth") + AlphaPoint = cls("FixtureAlpha_oM.PointLike") + BetaPoint = cls("FixtureBeta_oM.PointLike") + assert AlphaPoint is not BetaPoint + value = UsesBoth(FromAlpha=AlphaPoint(X=1.0), Local=BetaPoint(X=2.0)) + assert type(value.FromAlpha) is AlphaPoint and type(value.Local) is BetaPoint + with pytest.raises(ValidationError): + UsesBoth(FromAlpha=BetaPoint()) + + +def test_cyclic_references_import_and_validate(): + """Checks that two classes that reference each other import and validate, and that a nested instance of each can be built. + + Protects the cycle detection that decides which imports are placed after the class. + """ + CycleA = cls("FixtureAlpha_oM.CycleA") + CycleB = cls("FixtureAlpha_oM.CycleB") + a = CycleA(Other=CycleB(Other=CycleA())) + assert isinstance(cast(Any, a.Other), CycleB) + + +def test_unhashable_items_fall_back_to_lists(): + """Checks that collections of unhashable items are generated as lists (not sets) and that tuple lists and dictionaries still validate. + + Protects the type mapper's choice of collection type for item types that cannot go in a set. + """ + Collections = cls("FixtureAlpha_oM.Collections") + PointLike = cls("FixtureAlpha_oM.PointLike") + value = Collections(Points=[PointLike(X=1.0), PointLike(X=1.0)], ByPoint=[(PointLike(), "a")], Names={"a", "b"}, Lookup={"k": 1.0}) + assert len(value.Points) == 2 and value.ByPoint[0][1] == "a" + + +def test_metadata_markers_are_in_the_annotation(): + """Checks that a property's annotation carries its quantity (category and unit), display text, description and CLR type markers as the C# declares. + + Protects the metadata that the generator extracts and py2cs reads back. + """ + Beam = cls("FixtureAlpha_oM.Beam") + metadata = Beam.model_fields["Span"].metadata + kinds = {type(m).__name__ for m in metadata} + assert {"Quantity", "DisplayText"} <= kinds + quantity = next(m for m in metadata if type(m).__name__ == "Quantity") + assert (quantity.category, quantity.unit) == ("Length", "m") + assert Beam.model_fields["Span"].description == "Distance between the supports." + count_metadata = Beam.model_fields["Count"].metadata + assert [m.name for m in count_metadata if type(m).__name__ == "ClrType"] == ["long"] + + +def test_interface_fields_round_trip_through_json_and_pick_the_class_from_the_type_tag(): + """Checks the loop on generated classes: a GeometryHolder holding a LineLike and a PointLike is saved to JSON with `_t` and loads back with the same classes. + + Protects the generator writing Polymorphic for interface fields, together with the implementers listed in the interface markers. + """ + GeometryHolder, LineLike, PointLike = cls("FixtureAlpha_oM.GeometryHolder"), cls("FixtureAlpha_oM.LineLike"), cls("FixtureAlpha_oM.PointLike") + holder = GeometryHolder(Geometry=PointLike(X=1.0), Curves=[LineLike(Start=PointLike(X=2.0), Infinite=True)]) + data = holder.model_dump(mode="json") + assert data["Geometry"]["_t"] == "BH.oM.FixtureAlpha.PointLike" + assert data["Curves"][0]["_t"] == "BH.oM.FixtureAlpha.LineLike" and data["Curves"][0]["Start"]["X"] == 2.0 + + again = GeometryHolder.model_validate_json(holder.model_dump_json()) + assert again == holder and type(again.Geometry) is PointLike and type(again.Curves[0]) is LineLike + + +def test_a_line_is_accepted_as_a_geometry_and_as_a_curve_but_a_point_is_not_a_curve(): + """Checks the implementers lists: LineLike implements both interfaces, PointLike only IGeometryLike, so JSON naming a PointLike as a curve is refused.""" + GeometryHolder = cls("FixtureAlpha_oM.GeometryHolder") + line = {"_t": "BH.oM.FixtureAlpha.LineLike"} + assert type(GeometryHolder.model_validate({"Geometry": line, "Curves": [line]}).Geometry).__name__ == "LineLike" + with pytest.raises(ValidationError, match="BH.oM.FixtureAlpha.PointLike is not a class of ICurveLike"): + GeometryHolder.model_validate({"Curves": [{"_t": "BH.oM.FixtureAlpha.PointLike"}]}) + + +def test_the_error_for_an_interface_value_names_the_real_problem(): + """Checks that a LineLike whose `Infinite` has the wrong type reports that field, not a failure for every class the interface could be.""" + GeometryHolder = cls("FixtureAlpha_oM.GeometryHolder") + with pytest.raises(ValidationError) as error: + GeometryHolder.model_validate({"Geometry": {"_t": "BH.oM.FixtureAlpha.LineLike", "Infinite": "maybe"}}) + assert "Infinite" in str(error.value) and "PointLike" not in str(error.value) + + +def test_every_generated_class_has_a_json_schema(fixtures_package: Any): + """Checks that `model_json_schema()` works for every generated class, including every BHoMObject with its interface-typed Fragments field.""" + for name in module_names(fixtures_package): + generated = getattr(importlib.import_module(name), name.rsplit(".", 1)[-1]) + if isinstance(generated, type) and issubclass(generated, BaseModel): + assert generated.model_json_schema(), name + + +def test_generated_enums_are_written_by_name_and_read_from_names(): + """Checks the generated enums: SupportKind saves as its member name and `None_` as "None"; the flags enum saves a combination as a name and reads "A, B" back.""" + Holder, SupportKind, Options = cls("FixtureAlpha_oM.Beam"), cls("FixtureAlpha_oM.SupportKind"), cls("FixtureAlpha_oM.Options") + beam = Holder(Support=SupportKind.None_, Flags=Options.A | Options.B) + data = beam.model_dump(mode="json") + assert data["Support"] == "None" and data["Flags"] in ("A, B", "Both") + again = Holder.model_validate_json(beam.model_dump_json()) + assert again.Support is SupportKind.None_ and again.Flags == Options.A | Options.B + assert Holder.model_validate({"Support": {"_t": "System.Enum", "Value": "None"}}).Support is SupportKind.None_ diff --git a/.ci/unit-tests/python/test_py2cs/helpers.py b/.ci/unit-tests/python/test_py2cs/helpers.py new file mode 100644 index 0000000..bf42138 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/helpers.py @@ -0,0 +1,183 @@ +"""Builds tiny generated-style Python packages and C# trees on disk for the py2cs tests.""" + +from __future__ import annotations + +import json +import os +import textwrap +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +import pytest +from py2cs.github import MockGitHub, MockRepository + +GOLDEN = Path(__file__).resolve().parents[2] / "golden" / "py2cs" + + +def require_patcher() -> None: + """Skips the calling test when the built C# patcher is missing, or fails it when the CI environment variable is true. + + Every test that plans changes to an existing C# file calls it first, because those plans run the Roslyn patcher. + """ + from py2cs.patch import BUILD_HINT, default_patcher + + if default_patcher().exists(): + return + message = f"the C# patcher is not built; {BUILD_HINT}" + if os.environ.get("CI", "").lower() == "true": + pytest.fail(message) + pytest.skip(message) + + +def write(root: Path, relative: str, source: str) -> Path: + """Writes dedented source text to a file under root (creating folders) and returns its path. + + Used by the py2cs tests that need one hand-made source file on disk. + """ + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(source).lstrip("\n"), encoding="utf-8", newline="\n") + return path + + +def assert_golden(name: str, actual: str) -> None: + """Asserts that the text equals the committed golden file under golden/py2cs, ignoring line endings, or rewrites that file when UPDATE_GOLDEN=1. + + Protects the whole-file C# that csharp.emit_new drafts for new types from unintended changes. + """ + path = GOLDEN / name + actual = actual.replace("\r\n", "\n") + if os.environ.get("UPDATE_GOLDEN") == "1": + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(actual, encoding="utf-8", newline="\n") + return + assert path.exists(), f"missing golden file {path} (run with UPDATE_GOLDEN=1)" + assert actual == path.read_text(encoding="utf-8").replace("\r\n", "\n"), f"{name} differs" + + +def module( + cls: str, + clr: str, + namespace: str, + assembly: str, + repo: str | None = "BHoM/Demo_Toolkit", + kind: str = "class", + bases: str = "SchemaModel", + body: Iterable[str] = (), + imports: Iterable[str] = (), + marker_extra: Iterable[str] = (), + type_params: str = "", + doc: str | None = None, +) -> str: + """Builds the source text of one generated-style Python module (a class, enum or interface with its __bhom__ marker, imports and body) in the exact style the C# generator writes. + + The base of every hand-built package in the py2cs tests. + """ + lines = ["from __future__ import annotations", ""] + lines += ["from typing import Annotated, Any, ClassVar", "from pydantic import Field", "from bhom_schema._support import ClrType, DisplayText, Quantity, SchemaModel"] + lines += list(imports) + ["", "", f"class {cls}{type_params}({bases}):"] + if doc: + lines.append(f' """{doc}"""') + lines.append(" __bhom__: ClassVar[dict[str, Any]] = {") + lines += [f" 'clr_name': {clr!r},", f" 'namespace': {namespace!r},", f" 'assembly': {assembly!r},"] + if repo: + lines.append(f" 'repo': {repo!r},") + lines.append(f" 'kind': {kind!r},") + lines += [f" {x}," for x in marker_extra] + lines.append(" }") + body_lines = list(body) + if body_lines: + lines.append("") + lines += [f" {line}" for line in body_lines] + return "\n".join(lines) + "\n" + + +def package(root: Path, files: dict[str, str]) -> Path: + """Writes the given modules under /bhom_schema and returns that folder, which py2cs reads as the Python package (base or head of a change). + + Used by nearly every py2cs test to set up input. + """ + pkg = root / "bhom_schema" + pkg.mkdir(parents=True, exist_ok=True) + for relative, source in files.items(): + path = pkg / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8", newline="\n") + return pkg + + +def demo_package(root: Path, extra_fields: Iterable[str] = ()) -> Path: + """Builds the standard demo package in Demo_oM: an interface IdentityBase, a class ProjectInfo that derives from it (plus any extra fields) and an enum Mode. + + The default input of the diff, plan, align, PR and status tests. + """ + return package(root, { + "Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", + imports=["from abc import ABC"]), + "Demo_oM/ProjectInfo.py": module( + "ProjectInfo", "BH.oM.Demo.ProjectInfo", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, IdentityBase", + imports=["from bhom_schema.Demo_oM.IdentityBase import IdentityBase"], + body=["Reference: str = ''", "Name: str = ''", *extra_fields]), + "Demo_oM/Mode.py": module( + "Mode", "BH.oM.Demo.Mode", "BH.oM.Demo", "Demo_oM", kind="enum", bases="Enum", imports=["from enum import Enum"], + marker_extra=["'renamed_members': {'None_': 'None'}"], body=["None_ = 0", "# Faster.", "Fast = 1"]), + }) + + +def cs(namespace: str, declaration: str) -> str: + """Returns the text of a minimal C# file that declares one type in the given namespace. + + csharp_tree uses it to fake the existing .cs files of a C# repository. + """ + return f"using System;\n\nnamespace {namespace}\n{{\n {declaration}\n {{\n }}\n}}\n" + + +def csharp_tree(root: Path) -> Path: + """Writes a fake C# repository (Demo_Toolkit) whose folders do not follow the namespaces, with same-named types, several types in one file and an obj folder to ignore, and returns its path. + + The locate, place, plan, PR and breaking-change tests run against it. + """ + repo = root / "Demo_Toolkit" + files = { + "Demo_oM/AdministrativeInformation/ProjectInfo.cs": cs("BH.oM.Demo", "public class ProjectInfo : IdentityBase"), + "Demo_oM/AdministrativeInformation/IdentityBase.cs": cs("BH.oM.Demo", "public interface IdentityBase : IObject"), + "Demo_oM/Enums/Colour.cs": cs("BH.oM.Demo", "public enum Colour"), + "Demo_oM/Enums/Shade.cs": cs("BH.oM.Demo", "public enum Shade"), + "Demo_oM/Enums/Elements/Wall.cs": cs("BH.oM.Demo.Elements", "public class Wall : IObject"), + "Demo_oM/Misc/Several.cs": cs("BH.oM.Demo", "public class Alpha : IObject") + cs("BH.oM.Demo", "public class Beta : IObject"), + "Demo_oM/Curves/Line.cs": cs("BH.oM.Demo", "public class Line : IObject"), + "Demo_oM/Other/Line.cs": cs("BH.oM.Other", "public class Line : IObject"), + "Demo_oM/Elements/Door.cs": cs("BH.oM.Demo.Elements", "public class Door : IObject"), + "Demo_oM/obj/Debug/Ghost.cs": cs("BH.oM.Demo", "public class Ghost : IObject"), + "Other_oM/Thing.cs": cs("BH.oM.Other", "public class Thing : IObject"), + } + for relative, text in files.items(): + path = repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + return repo + + +class Recorder: + """A RestClient transport for tests: answers from a table of canned replies and records every request it receives.""" + + def __init__(self, answers: dict[tuple[str, str], tuple[int, Any]]): + """Stores the canned answers keyed by (method, path) and starts an empty request log.""" + self.answers = answers + self.requests: list[tuple[str, str, dict[str, str], Any]] = [] + + def __call__(self, method: str, url: str, headers: dict[str, str], body: bytes | None) -> tuple[int, bytes]: + """Acts as the RestClient transport: records the request (method, path, headers, parsed body) and returns the canned status and JSON, or 404 Not Found.""" + path = url.removeprefix("https://api.github.com") + self.requests.append((method, path, headers, json.loads(body) if body else None)) + status, data = self.answers.get((method, path), (404, {"message": "Not Found"})) + return status, json.dumps(data).encode() + + +def mock_github(*repos: str) -> MockGitHub: + """Builds a MockGitHub that holds the Python repository BHoM/BHoM_PythonSchema and an empty repository for each name given. + + The linked-PR and status tests use it as the whole of GitHub, so nothing touches the network. + """ + return MockGitHub({"BHoM/BHoM_PythonSchema": MockRepository(), **{repo: MockRepository() for repo in repos}}) diff --git a/.ci/unit-tests/python/test_py2cs/test_authoring.py b/.ci/unit-tests/python/test_py2cs/test_authoring.py new file mode 100644 index 0000000..00af0aa --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_authoring.py @@ -0,0 +1,311 @@ +"""Python written by people, not by the generator: common idioms, classes without a __bhom__ marker, and clear errors.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from helpers import demo_package, module, package, write +from py2cs.__main__ import main +from py2cs.check import check_package +from py2cs.csharp import emit_new +from py2cs.diff import diff_manifests +from py2cs.reader import ReadError, ReadErrors, read_package + +def demo_with_folders(tmp_path: Path) -> Path: + """Builds the demo package plus a generated Wall class in a Demo_oM/Elements sub folder. + + test_namespace_of_a_new_folder_follows_the_generator_rule uses it so the reader can work out the namespace of a class in a new sub folder. + """ + pkg = demo_package(tmp_path) + (pkg / "Demo_oM" / "Elements").mkdir() + (pkg / "Demo_oM" / "Elements" / "Wall.py").write_text(module("Wall", "BH.oM.Demo.Elements.Wall", "BH.oM.Demo.Elements", "Demo_oM"), encoding="utf-8") + return pkg + +# ---------------------------------------------------------------- idioms + +def test_common_typing_idioms_are_understood(tmp_path: Path): + """Checks that the reader understands the usual ways a person writes a field (Optional, Union, List, Dict, typing., Field with positional or keyword default, required Field, quoted forward reference, ClassVar skipped) and gives the right C# type, default and description. + + Protects the reader stage of py2cs, which must accept hand-written Python and not only generator output. + """ + pkg = demo_package(tmp_path) + write(pkg, "Demo_oM/Idioms.py", """ + import typing + from typing import Dict, List, Optional, Union + import pydantic + from pydantic import Field + from bhom_schema._support import SchemaModel + from bhom_schema.Demo_oM.Mode import Mode + + class Idioms(SchemaModel): + \"\"\"Every common way of writing a field.\"\"\" + Stage: Optional[int] = None + Other: Union[None, str] = None + Names: List[str] = Field(default_factory=list) + Lookup: Dict[str, float] = Field(default_factory=dict) + Qualified: typing.List[int] = Field(default_factory=list) + Positional: int = Field(0, description="RIBA stage") + Keyword: int = pydantic.Field(default=2) + Required: str = Field(..., description="Must be given") + Forward: "Mode | None" = None + Constant: typing.ClassVar[int] = 3 + """) + info = read_package(pkg)["BH.oM.Demo.Idioms"] + props = {p.name: p for p in info.properties} + assert {n: p.clr_type for n, p in props.items()} == { + "Stage": "int?", "Other": "string", "Names": "System.Collections.Generic.List", + "Lookup": "System.Collections.Generic.Dictionary", "Qualified": "System.Collections.Generic.List", + "Positional": "int", "Keyword": "int", "Required": "string", "Forward": "BH.oM.Demo.Mode?"} + assert (props["Positional"].has_default, props["Positional"].default, props["Positional"].description) == (True, "0", "RIBA stage") + assert (props["Keyword"].has_default, props["Keyword"].default) == (True, "2") + assert (props["Required"].has_default, props["Required"].description) == (False, "Must be given") + assert "Constant" not in props + +def test_positional_field_default_drafts_a_settable_property(tmp_path: Path): + """Checks that Field(0, description=...) is read as a field with a default, so the drafted C# property is settable with an initial value and the class gets no read-only constructor. + + Protects the reader and csharp.emit_new from a past bug that read such a field as required. + """ + pkg = demo_package(tmp_path, ["Stage: int = Field(0, description='RIBA stage')"]) + manifest = read_package(pkg) + text = emit_new(manifest["BH.oM.Demo.ProjectInfo"], manifest) + assert "public virtual int Stage { get; set; } = 0;" in text + assert "public ProjectInfo(" not in text + +# ---------------------------------------------------------------- worked-out markers + +def test_a_new_class_without_a_marker_is_read_not_skipped(tmp_path: Path): + """Checks that a hand-written class with no __bhom__ marker is read with namespace, assembly, repository, kind and bases worked out from its folder and neighbours, and that diff reports it as added. + + Protects the diff stage from a past bug where such a class was skipped and the PR looked unchanged. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + write(head_root, "Demo_oM/ProjectStage.py", """ + from bhom_schema._support import SchemaModel + from bhom_schema.Demo_oM.IdentityBase import IdentityBase + + class ProjectStage(SchemaModel, IdentityBase): + \"\"\"A RIBA stage.\"\"\" + Stage: int = 0 + """) + head = read_package(head_root) + info = head["BH.oM.Demo.ProjectStage"] + assert (info.namespace, info.assembly, info.repo, info.kind) == ("BH.oM.Demo", "Demo_oM", "BHoM/Demo_Toolkit", "class") + assert info.bases == ["BH.oM.Demo.IdentityBase"] + assert info.notes and "worked out" in info.notes[0] + assert [(c.clr_name, c.kind) for c in diff_manifests(base, head).changes] == [("BH.oM.Demo.ProjectStage", "added")] + +def test_namespace_of_a_new_folder_follows_the_generator_rule(tmp_path: Path): + """Checks that a class in a new sub folder gets the namespace the generator would use (the parent namespace plus the folder name). + + Protects the namespace of the new C# files that py2cs drafts from hand-written Python. + """ + pkg = demo_with_folders(tmp_path) + write(pkg, "Demo_oM/Elements/Openings/Door.py", """ + from bhom_schema._support import SchemaModel + + class Door(SchemaModel): + Width: float = 0.9 + """) + info = read_package(pkg)["BH.oM.Demo.Elements.Openings.Door"] + assert info.namespace == "BH.oM.Demo.Elements.Openings" + +def test_kind_generic_and_enum_details_are_worked_out(tmp_path: Path): + """Checks that the reader works out an interface (I prefix), a generic class with constraints (PEP 695 type parameters) and a flags enum (IntFlag, None_ read as None) for classes without a marker. + + Protects the drafting of new C# types from hand-written Python. + """ + pkg = demo_package(tmp_path) + write(pkg, "Demo_oM/ILabelled.py", """ + from bhom_schema.Demo_oM.IdentityBase import IdentityBase + + class ILabelled(IdentityBase): + Label: str + """) + write(pkg, "Demo_oM/Group_1.py", """ + from pydantic import Field + from bhom_schema._support import SchemaModel + from bhom_schema.Demo_oM.ILabelled import ILabelled + + class Group_1[T: ILabelled](SchemaModel): + Items: list[T] = Field(default_factory=list) + """) + write(pkg, "Demo_oM/Access.py", """ + from enum import IntFlag + + class Access(IntFlag): + None_ = 0 + Read = 1 + Write = 2 + """) + manifest = read_package(pkg) + assert manifest["BH.oM.Demo.ILabelled"].kind == "interface" + assert [(p.name, p.clr_type) for p in manifest["BH.oM.Demo.ILabelled"].properties] == [("Label", "string")] + group = manifest["BH.oM.Demo.Group`1"] + assert (group.name, group.generic_params[0].constraints) == ("BH.oM.Demo.Group", ["BH.oM.Demo.ILabelled"]) + access = manifest["BH.oM.Demo.Access"] + assert access.kind == "enum" and access.flags and [m.name for m in access.members] == ["None", "Read", "Write"] + +def test_a_new_assembly_folder_is_an_error(tmp_path: Path): + """Checks that a class in a folder that is not an existing assembly folder (Brand_oM) is a read error. + + Protects the reader from inventing a C# assembly, which only a person can create. + """ + pkg = demo_package(tmp_path) + write(pkg, "Brand_oM/Thing.py", """ + from bhom_schema._support import SchemaModel + + class Thing(SchemaModel): + X: int = 0 + """) + with pytest.raises(ReadErrors, match="Brand_oM is not an existing assembly folder"): + read_package(pkg) + +# ---------------------------------------------------------------- errors + +def test_problems_are_collected_per_file_and_the_rest_is_read(tmp_path: Path): + """Checks that read errors (a second class in a file, an unsupported type, an underscore field name) are collected with file and line while the rest of the package is still read. + + Lets one py2cs run report every Python problem of a PR at once. + """ + pkg = demo_package(tmp_path) + write(pkg, "Demo_oM/Broken.py", """ + from collections.abc import Sequence + from bhom_schema._support import SchemaModel + + class Broken(SchemaModel): + Good: int = 0 + Bad: Sequence[int] = () + _hidden: int = 0 + + class Helper(SchemaModel): + X: int = 0 + """) + errors: list[ReadError] = [] + manifest = read_package(pkg, errors) + assert "BH.oM.Demo.ProjectInfo" in manifest + assert [p.name for p in manifest["BH.oM.Demo.Broken"].properties] == ["Good"] + messages = [str(e) for e in errors] + assert messages == [ + "Demo_oM/Broken.py:9: only one class per file is read; move Helper to its own file", + "Demo_oM/Broken.py:6: Bad: unsupported type 'Sequence'; use str, int, float, bool, Decimal, datetime, timedelta, UUID, Any, list[...], dict[...], " + "set[...], tuple[...], X | None, or a bhom_schema class", + "Demo_oM/Broken.py:7: _hidden: a field name cannot start with an underscore (Pydantic treats it as private)", + ] + +def test_two_files_declaring_the_same_class_is_an_error(tmp_path: Path): + """Checks that the same C# class declared in two Python files is reported as one read error naming both files. + + Protects the manifest, which needs a single source for each class. + """ + pkg = demo_package(tmp_path) + (pkg / "Demo_oM" / "Copy.py").write_text((pkg / "Demo_oM" / "ProjectInfo.py").read_text(encoding="utf-8"), encoding="utf-8") + errors: list[ReadError] = [] + read_package(pkg, errors) + assert [str(e) for e in errors] == ["Demo_oM/ProjectInfo.py: BH.oM.Demo.ProjectInfo is already declared in Demo_oM/Copy.py"] + +def test_command_line_reports_problems_instead_of_a_traceback(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs diff prints the Python read errors on stderr and exits with 1 instead of a traceback. + + Protects the diff command that the linked-PR workflow runs on the PR's Python. + """ + demo_package(tmp_path / "base") + head = demo_package(tmp_path / "head", ["Stage: Sequence[int] = ()"]) + status = main(["diff", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(head), "--out", str(tmp_path / "d.json")]) + assert status == 1 + err = capsys.readouterr().err + assert "error: Demo_oM/ProjectInfo.py:" in err and "unsupported type 'Sequence'" in err and "1 problems in the Python files" in err + +# ---------------------------------------------------------------- check + +def test_check_lists_errors_and_missing_descriptions(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that check_package returns warnings for unmarked classes and missing class or property descriptions, that py2cs check exits 0 for warnings only, and exits 1 and prints the error for an unsupported union. + + Protects the check step of the PR checks, which lists problems of hand-written Python before any C# is drafted. + """ + base = demo_package(tmp_path / "base") + head = demo_package(tmp_path / "head", ["Stage: int = 0", "Note: str = Field(default='', description='A note.')"]) + write(head, "Demo_oM/Fresh.py", """ + from bhom_schema._support import SchemaModel + + class Fresh(SchemaModel): + X: int = 0 + """) + result = check_package(head, base) + assert result.errors == [] + assert result.warnings == [ + "Demo_oM/Fresh.py: no __bhom__ marker was written, so it was worked out: C# name BH.oM.Demo.Fresh, repository BHoM/Demo_Toolkit, kind class", + "Demo_oM/Fresh.py: Fresh has no description; add a docstring (BHoM requires one on every oM class)", + "Demo_oM/Fresh.py: Fresh.X has no description; add Field(description=...) (BHoM requires one on every oM property)", + "Demo_oM/ProjectInfo.py: ProjectInfo.Stage has no description; add Field(description=...) (BHoM requires one on every oM property)", + ] + + assert main(["check", "--root", str(head)]) == 0 + write(head, "Demo_oM/Bad.py", """ + from bhom_schema._support import SchemaModel + + class Bad(SchemaModel): + X: int | str = 0 + """) + capsys.readouterr() + assert main(["check", "--root", str(head)]) == 1 + assert "error: Demo_oM/Bad.py:4: X: unsupported union" in capsys.readouterr().out + +def test_polymorphic_fields_and_enum_bases_of_the_generated_package_are_read(tmp_path: Path): + """Checks that py2cs reads what the generator now writes: `Polymorphic[X]` is the plain interface X (also in a list, an optional and a generic bound), the enum bases + BHoMEnum and BHoMFlags give an enum and a flags enum, and the `implementers` of a marker are ignored. Protects the Python to C# path from the JSON support. + """ + pkg = demo_package(tmp_path) + write(pkg, "Demo_oM/ILabelled.py", """ + from typing import Any, ClassVar + from bhom_schema.Demo_oM.IdentityBase import IdentityBase + + class ILabelled(IdentityBase): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Demo.ILabelled', 'assembly': 'Demo_oM', 'namespace': 'BH.oM.Demo', 'kind': 'interface', + 'implementers': { + 'BH.oM.Demo.ProjectInfo': 'Demo_oM.ProjectInfo', + }, + } + """) + write(pkg, "Demo_oM/Holder_1.py", """ + from pydantic import Field + from bhom_schema._support import Polymorphic, SchemaModel + from bhom_schema.Demo_oM.ILabelled import ILabelled + + class Holder_1[T: Polymorphic[ILabelled]](SchemaModel): + \"\"\"Holds interface values.\"\"\" + One: Polymorphic[ILabelled] + Maybe: Polymorphic[ILabelled] | None = None + Many: list[Polymorphic[ILabelled]] = Field(default_factory=list) + Items: list[T] = Field(default_factory=list) + """) + write(pkg, "Demo_oM/Level.py", """ + from bhom_schema._support import BHoMEnum + + class Level(BHoMEnum): + Low = 1 + High = 2 + """) + write(pkg, "Demo_oM/Access.py", """ + from bhom_schema._support import BHoMFlags + + class Access(BHoMFlags): + Read = 1 + Write = 2 + """) + manifest = read_package(pkg) + + holder = manifest["BH.oM.Demo.Holder`1"] + assert {p.name: p.clr_type for p in holder.properties} == { + "One": "BH.oM.Demo.ILabelled", "Maybe": "BH.oM.Demo.ILabelled", "Many": "System.Collections.Generic.List", + "Items": "System.Collections.Generic.List"} + assert holder.generic_params[0].constraints == ["BH.oM.Demo.ILabelled"] + + assert manifest["BH.oM.Demo.ILabelled"].kind == "interface" + assert manifest["BH.oM.Demo.Level"].kind == "enum" and not manifest["BH.oM.Demo.Level"].flags + assert manifest["BH.oM.Demo.Access"].kind == "enum" and manifest["BH.oM.Demo.Access"].flags + assert [m.name for m in manifest["BH.oM.Demo.Access"].members] == ["Read", "Write"] diff --git a/.ci/unit-tests/python/test_py2cs/test_csharp_emission.py b/.ci/unit-tests/python/test_py2cs/test_csharp_emission.py new file mode 100644 index 0000000..6b2d161 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_csharp_emission.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import assert_golden, module, package +from py2cs.csharp import Names, emit_new, parse_type +from py2cs.reader import read_package + + +def draft(tmp_path: Path, files: dict[str, str], clr: str) -> str: + """Reads a package built from the given files and returns the C# that emit_new drafts for one class, with required fields as read-only properties. + + Used by test_new_class_with_every_property_kind. + """ + manifest = read_package(package(tmp_path, files)) + return emit_new(manifest[clr], manifest, read_only_required=True) + + +def test_names_shorten_and_collect_usings(): + """Checks that Names shortens type names to their simple names, collects the needed usings and keeps a second type with the same simple name fully qualified. + + Protects the type names and usings of the C# files that csharp.emit_new writes. + """ + names = Names("BH.oM.Demo") + assert names.render("System.Collections.Generic.Dictionary") == "Dictionary" + assert names.render("BH.oM.Demo.Local[]") == "Local[]" + assert names.render("int?") == "int?" + assert names.render("BH.oM.Other.Point") == "BH.oM.Other.Point", "a second type with the same simple name stays qualified" + assert names.usings == {"System.Collections.Generic", "BH.oM.Geometry"} + + +def test_parse_type_shapes(): + """Checks that parse_type splits a C# type text into its name, generic arguments and nullable or array suffixes. + + Protects the type handling underneath csharp.emit_new. + """ + node = parse_type("System.Tuple>?") + assert node.name == "System.Tuple" and node.suffix == "?" and node.args[1].args[0].suffix == "[,]" + + +def test_new_class_with_every_property_kind(tmp_path: Path): + """Checks that emit_new drafts a class with every kind of property (required, each default type, enum defaults, collections, UUID, decimal, datetime, quantity, display text, description) exactly as the committed Everything.cs. + + Protects the whole-file C# drafting of new classes. + """ + files = { + "Demo_oM/Mode.py": module("Mode", "BH.oM.Demo.Mode", "BH.oM.Demo", "Demo_oM", kind="enum", bases="Enum", imports=["from enum import Enum"], + marker_extra=["'renamed_members': {'None_': 'None'}"], body=["None_ = 0", "Fast = 1"]), + "Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Everything.py": module( + "Everything", "BH.oM.Demo.Everything", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, IdentityBase", doc="Has every kind of property.", + imports=["from bhom_schema.Demo_oM.IdentityBase import IdentityBase", "from bhom_schema.Demo_oM.Mode import Mode", + "from datetime import datetime, timedelta", "from decimal import Decimal", "from uuid import UUID, uuid4"], + body=[ + "Required: str", + "Text: str = ''", + "Quoted: str = 'say \"hi\"'", + "Count: Annotated[int, ClrType('long')] = 3", + "Small: Annotated[float, ClrType('float')] = 0.5", + "Ratio: float = 1.5", + "Flag: bool = Field(default=False, description='A flag.')", + "Choice: Mode = Mode.None_", + "Other: Mode = Mode.Fast", + "Names: set[str] = Field(default_factory=set)", + "Lookup: dict[str, float] = Field(default_factory=dict)", + "Values: Annotated[list[float], ClrType('double[]')] = Field(default_factory=list)", + "Words: Annotated[list[str], ClrType('System.Collections.Generic.IEnumerable')] = Field(default_factory=lambda: ['a', 'b'])", + "Maybe: int | None = None", + "Key: UUID = UUID(int=0)", + "Fresh: UUID = Field(default_factory=uuid4)", + "Cost: Decimal = Decimal('1.5')", + "Fixed: datetime = datetime(2020, 1, 2, 3, 4, 5, 0)", + "Created: datetime = Field(default_factory=datetime.now)", + "Span: Annotated[float, Quantity('Length', 'm'), DisplayText('Span length')] = Field(default=1.5, description='Distance between supports.')", + ]), + } + assert_golden("Everything.cs", draft(tmp_path, files, "BH.oM.Demo.Everything")) + + +def test_new_enum_interface_and_generic(tmp_path: Path): + """Checks that emit_new drafts a new enum, flags enum, interface and generic class exactly as the committed golden files. + + Protects the whole-file C# drafting of the kinds other than plain classes. + """ + files = { + "Demo_oM/Mode.py": module("Mode", "BH.oM.Demo.Mode", "BH.oM.Demo", "Demo_oM", kind="enum", bases="Enum", imports=["from enum import Enum"], + marker_extra=["'renamed_members': {'None_': 'None'}"], body=["None_ = 0", "# Quick.", "Fast = 1"], doc="Speeds."), + "Demo_oM/Flags.py": module("Flags", "BH.oM.Demo.Flags", "BH.oM.Demo", "Demo_oM", kind="enum", bases="IntFlag", imports=["from enum import IntFlag"], + marker_extra=["'flags': True"], body=["A = 1", "B = 2", "Both = 3"]), + "Demo_oM/ILabelled.py": module("ILabelled", "BH.oM.Demo.ILabelled", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"], + marker_extra=["'properties': {'Label': {'type': 'string', 'description': 'The label.'}}"]), + "Demo_oM/Box_1.py": module( + "Box_1", "BH.oM.Demo.Box`1", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, ILabelled", type_params="[T: ILabelled]", + marker_extra=["'generic_params': {'T': ['BH.oM.Demo.ILabelled']}"], + imports=["from bhom_schema.Demo_oM.ILabelled import ILabelled"], body=["Items: list[T] = Field(default_factory=list[T])", "Label: str | None = None"]), + } + manifest = read_package(package(tmp_path, files)) + assert_golden("Mode.cs", emit_new(manifest["BH.oM.Demo.Mode"], manifest)) + assert_golden("Flags.cs", emit_new(manifest["BH.oM.Demo.Flags"], manifest)) + assert_golden("ILabelled.cs", emit_new(manifest["BH.oM.Demo.ILabelled"], manifest)) + assert_golden("Box.cs", emit_new(manifest["BH.oM.Demo.Box`1"], manifest)) + + +def test_untranslatable_default_becomes_a_reviewer_note(tmp_path: Path): + """Checks that a default which cannot be translated to C# gives a property without an initialiser and a note for the reviewer. + + Protects the pull request text, which tells the reviewer what to set by hand. + """ + files = {"Demo_oM/Odd.py": module("Odd", "BH.oM.Demo.Odd", "BH.oM.Demo", "Demo_oM", body=["Value: str = some_function()"])} + manifest = read_package(package(tmp_path, files)) + notes: list[str] = [] + text = emit_new(manifest["BH.oM.Demo.Odd"], manifest, notes) + assert "public virtual string Value { get; set; }\n" in text + assert notes == ["the default some_function() of Value could not be translated to C#; set it by hand"] + + +def test_new_fields_without_a_default_stay_settable(tmp_path: Path): + """Checks that, without read-only required fields, a field with no default is drafted as a settable property with a note, and a positional Field default keeps its initialiser and gets no constructor. + + Protects the drafting of hand-written classes. + """ + files = {"Demo_oM/Loose.py": module("Loose", "BH.oM.Demo.Loose", "BH.oM.Demo", "Demo_oM", body=["Id: str", "Stage: int = Field(0, description='RIBA stage')"])} + manifest = read_package(package(tmp_path, files)) + notes: list[str] = [] + text = emit_new(manifest["BH.oM.Demo.Loose"], manifest, notes) + assert "public virtual string Id { get; set; }\n" in text + assert "public virtual int Stage { get; set; } = 0;" in text + assert "public Loose(" not in text + assert notes == ["Id has no default in Python, so it was drafted without an initial value"] + + +def test_required_fields_become_get_only_properties_set_by_a_constructor(tmp_path: Path): + """Checks that, with read-only required fields, required fields become get-only properties set by a constructor, with reserved words escaped (@class), and match the committed Fixed.cs. + + Protects the C# drafted from generated Python with required fields. + """ + files = {"Demo_oM/Fixed.py": module("Fixed", "BH.oM.Demo.Fixed", "BH.oM.Demo", "Demo_oM", + body=["Id: str", "class_: int = Field(alias='class')", "Length: float"])} + manifest = read_package(package(tmp_path, files)) + text = emit_new(manifest["BH.oM.Demo.Fixed"], manifest, read_only_required=True) + assert "public virtual string Id { get; }" in text + assert "public Fixed(string id, int @class, double length)" in text + assert "Id = id;" in text and "@class = @class;" in text and "public virtual int @class { get; }" in text + assert_golden("Fixed.cs", text) + + +def test_same_simple_name_in_two_namespaces_is_written_in_full(tmp_path: Path): + """Checks that two types with the same simple name in different namespaces are written fully qualified in the drafted C#. + + Protects the drafted file from ambiguous or wrong type names. + """ + files = { + "A_oM/Point.py": module("Point", "BH.oM.A.Point", "BH.oM.A", "A_oM"), + "B_oM/Point.py": module("Point", "BH.oM.B.Point", "BH.oM.B", "B_oM"), + "B_oM/Holder.py": module("Holder", "BH.oM.B.Holder", "BH.oM.B", "B_oM", + imports=["from bhom_schema.A_oM.Point import Point as A_Point", "from bhom_schema.B_oM.Point import Point"], + body=["FromA: A_Point | None = None", "Local: Point | None = None"]), + } + manifest = read_package(package(tmp_path, files)) + text = emit_new(manifest["BH.oM.B.Holder"], manifest) + assert "public virtual BH.oM.A.Point FromA { get; set; }" in text + assert "public virtual BH.oM.B.Point Local { get; set; }" in text diff --git a/.ci/unit-tests/python/test_py2cs/test_documentation.py b/.ci/unit-tests/python/test_py2cs/test_documentation.py new file mode 100644 index 0000000..e4f5a23 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_documentation.py @@ -0,0 +1,33 @@ +"""Every function in the repository's own Python code says what it does.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +REPO = Path(__file__).resolve().parents[4] +FOLDERS = [REPO / ".ci", REPO / "src"] +SKIPPED = {"_output", ".venv", "__pycache__", "bin", "obj", ".vs", "TestResults", "golden"} + +def python_files() -> list[Path]: + """Finds the Python files of the repository that are written by hand or by the converters' own tools.""" + found: list[Path] = [] + for folder in FOLDERS: + found += [p for p in folder.rglob("*.py") if not SKIPPED & set(p.relative_to(REPO).parts)] + return sorted(found) + +def undocumented(path: Path) -> list[str]: + """Lists the functions of one file that have no docstring, as 'file:line name'.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + return [f"{path.relative_to(REPO).as_posix()}:{node.lineno} {node.name}" for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ast.get_docstring(node, clean=False) is None] + +def test_every_function_has_a_docstring(): + """Fails with the list of functions that have no docstring, so a new function cannot be added without saying what it does.""" + missing = [entry for path in python_files() for entry in undocumented(path)] + assert missing == [], f"{len(missing)} functions have no docstring:\n" + "\n".join(missing[:60]) + +def test_the_search_covers_the_converters_and_their_tests(): + """Guards the check above against silently looking at nothing, for example after a folder is renamed.""" + names = {p.name for p in python_files()} + assert {"reader.py", "pr.py", "github.py", "test_linked_pr.py", "support.py"} <= names diff --git a/.ci/unit-tests/python/test_py2cs/test_lifecycle.py b/.ci/unit-tests/python/test_py2cs/test_lifecycle.py new file mode 100644 index 0000000..f065b87 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_lifecycle.py @@ -0,0 +1,249 @@ +"""Keeping the Python PR and the C# PR in step: breaking changes, and the alignment check.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from helpers import csharp_tree, demo_package, module, package, require_patcher +from py2cs.__main__ import main +from py2cs.align import align +from py2cs.diff import Diff, breaking_changes, diff_manifests +from py2cs.plan import make_plan +from py2cs.reader import read_package +from py2cs.report import render_report + +ENUM = {"kind": "enum", "bases": "Enum", "imports": ["from enum import Enum"]} + +def thing(root: Path, body: list[str], **options: object): + """Builds a package with one Demo_oM class Thing from the given body and marker options and returns its manifest entry. + + The breaking-change tests use it to compare two versions of the same class. + """ + return read_package(package(root, {"Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", body=body, **options)}))["BH.oM.Demo.Thing"] # type: ignore[arg-type] + +# ---------------------------------------------------------------- breaking changes + +def test_removed_property_is_breaking(tmp_path: Path): + """Checks that removing a property is reported as a breaking change. + + Protects the breaking-change label and report section that the diff and plan stages produce. + """ + before, after = thing(tmp_path / "a", ["A: int = 0", "B: str = ''"]), thing(tmp_path / "b", ["A: int = 0"]) + assert breaking_changes(before, after) == ["property removed: string B"] + +def test_a_removed_and_an_added_property_of_the_same_type_is_a_rename(tmp_path: Path): + """Checks that one removed and one added property of the same type are reported as a rename, not as a removal. + + Keeps the breaking-change notes of the linked C# PR accurate. + """ + before, after = thing(tmp_path / "a", ["Old: str = ''", "A: int = 0"]), thing(tmp_path / "b", ["A: int = 0", "New: str = ''"]) + assert breaking_changes(before, after) == ["property renamed: Old to New (string)"] + +def test_several_candidates_are_not_guessed_as_renames(tmp_path: Path): + """Checks that when several removed and added properties share a type, the removals are reported and no rename is guessed. + + Protects the breaking-change list from wrong rename claims. + """ + before, after = thing(tmp_path / "a", ["X: str = ''", "Y: str = ''"]), thing(tmp_path / "b", ["P: str = ''", "Q: str = ''"]) + assert breaking_changes(before, after) == ["property removed: string X", "property removed: string Y"] + +def test_type_change_is_breaking_but_an_added_property_or_new_default_is_not(tmp_path: Path): + """Checks that changing a property type is breaking while adding a property or changing a default is not. + + Protects the line between breaking and safe changes in breaking_changes. + """ + before = thing(tmp_path / "a", ["A: int = 0", "B: str = ''"]) + assert breaking_changes(before, thing(tmp_path / "b", ["A: float = 0.0", "B: str = ''"])) == ["property A type changed from int to double"] + assert breaking_changes(before, thing(tmp_path / "c", ["A: int = 5", "B: str = 'x'", "C: int = 0"])) == [] + +def test_enum_member_removed_or_renumbered_is_breaking_but_added_is_not(tmp_path: Path): + """Checks that removing or renumbering an enum member is breaking while adding one is not. + + Protects the breaking-change rules for enums, whose values may be stored by users. + """ + before = thing(tmp_path / "a", ["A = 1", "B = 2", "C = 3"], **ENUM) + after = thing(tmp_path / "b", ["A = 1", "B = 5", "D = 4"], **ENUM) + assert breaking_changes(before, after) == ["enum member B value changed from 2 to 5", "enum member removed: C"] + assert breaking_changes(before, thing(tmp_path / "c", ["A = 1", "B = 2", "C = 3", "D = 4"], **ENUM)) == [] + +def test_removed_base_and_changed_kind_are_breaking(tmp_path: Path): + """Checks that removing a base type or changing the kind of a class is breaking while adding a base is not. + + Protects the breaking-change rules for inheritance and class kind. + """ + interface = {"Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"])} + imports = ["from bhom_schema.Demo_oM.IdentityBase import IdentityBase"] + + def version(name: str, **options: object): + """Builds a package with the IdentityBase interface and a Thing class made with the given options, and returns the manifest entry of Thing. + + Used by the removed-base and changed-kind test to make its versions of Thing. + """ + files = {**interface, "Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", **options)} # type: ignore[arg-type] + return read_package(package(tmp_path / name, files))["BH.oM.Demo.Thing"] + + with_base = version("a", bases="SchemaModel, IdentityBase", imports=imports) + without = version("b", bases="SchemaModel") + assert breaking_changes(with_base, without) == ["base removed: BH.oM.Demo.IdentityBase"] + assert breaking_changes(without, with_base) == [] + assert breaking_changes(without, version("c", kind="abstract", bases="SchemaModel, ABC", imports=["from abc import ABC"])) == ["kind changed from class to abstract"] + +def test_diff_carries_breaking_changes_and_keeps_them_through_json(tmp_path: Path): + """Checks that diff_manifests records breaking changes on each change (removed type, renamed property) and that the diff is unchanged after a JSON round trip. + + Protects the diff file that passes between the jobs of the workflows. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Mode.py").unlink() + (head_root / "Demo_oM" / "ProjectInfo.py").write_text(module( + "ProjectInfo", "BH.oM.Demo.ProjectInfo", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, IdentityBase", + imports=["from bhom_schema.Demo_oM.IdentityBase import IdentityBase"], body=["Reference: str = ''", "Title: str = ''"]), encoding="utf-8") + diff = diff_manifests(base, read_package(head_root)) + by_name = {c.clr_name: c for c in diff.changes} + assert by_name["BH.oM.Demo.Mode"].breaking == ["type removed"] + assert by_name["BH.oM.Demo.ProjectInfo"].breaking == ["property renamed: Name to Title (string)"] + assert Diff.from_json(json.loads(json.dumps(diff.to_json()))) == diff + +def test_plan_labels_and_reports_breaking_changes(tmp_path: Path): + """Checks that make_plan gives the plan the breaking-change label when a type is removed and that the report has a Breaking changes section that names Versioning_Toolkit. + + Protects the label and text of the linked C# PR. + """ + clones = tmp_path / "clones" + repo = csharp_tree(clones) + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Mode.py").unlink() + head = read_package(head_root) + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo, {"BHoM/Demo_Toolkit": ["Demo_oM"]}) + assert plan.labels() == ["breaking-change"] + assert json.loads(json.dumps(plan.summary()))["labels"] == ["breaking-change"] + report = render_report(plan) + assert "## Breaking changes" in report and "Versioning_Toolkit" in report and "- `BH.oM.Demo.Mode`: type removed" in report + +def test_plan_for_a_modified_type_lists_its_breaking_changes(tmp_path: Path): + """Checks that a planned patch of a modified class carries its breaking changes into the plan file entry, the label and the report text. + + Protects the review text of the linked C# PR for changes to existing files. + """ + require_patcher() + clones = tmp_path / "clones" + repo = csharp_tree(clones) + base = read_package(demo_package(tmp_path / "base", ["Count: int = 0"])) + head = read_package(demo_package(tmp_path / "head", ["Count: float = 0.0"])) + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo, {"BHoM/Demo_Toolkit": ["Demo_oM"]}) + assert plan.errors == [] + assert plan.files[0].breaking == ["property Count type changed from int to double"] + assert plan.labels() == ["breaking-change"] + assert "Breaking changes:\n- property Count type changed from int to double" in render_report(plan) + +def test_plan_without_breaking_changes_has_no_label(tmp_path: Path): + """Checks that a plan with only a safe change has no labels and no Breaking changes section in the report. + + Protects the linked C# PR from false breaking-change labels. + """ + require_patcher() + clones = tmp_path / "clones" + repo = csharp_tree(clones) + base = read_package(demo_package(tmp_path / "base")) + head = read_package(demo_package(tmp_path / "head", ["ProjectNumber: str = ''"])) + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo, {"BHoM/Demo_Toolkit": ["Demo_oM"]}) + assert plan.labels() == [] and "## Breaking changes" not in render_report(plan) + +# ---------------------------------------------------------------- the alignment check + +def versions(tmp_path: Path, merged_extra: list[str] | None = None): + """Builds the three manifests that align compares: the base, a head that adds ProjectNumber, and the package generated from the merged C# (with merged_extra fields, or none when None). + + Used by the align tests. + """ + base = read_package(demo_package(tmp_path / "base")) + head = read_package(demo_package(tmp_path / "head", ["ProjectNumber: str = ''"])) + generated = read_package(demo_package(tmp_path / "generated", merged_extra if merged_extra is not None else [])) + return base, head, generated + +def test_same_python_passes(tmp_path: Path): + """Checks that align passes and lists the checked class when the PR's Python equals what the generator wrote from the merged C#. + + Protects the align step of the PR checks from false alarms. + """ + base, head, generated = versions(tmp_path, ["ProjectNumber: str = ''"]) + result = align(base, head, generated) + assert result.ok and result.checked == ["BH.oM.Demo.ProjectInfo"] + +def test_a_different_default_fails(tmp_path: Path): + """Checks that align reports a property whose default differs between the merged C# and the PR's Python. + + Protects the align step, which makes the Python match the C#, the source of truth. + """ + base, head, generated = versions(tmp_path, ["ProjectNumber: str = 'n/a'"]) + assert align(base, head, generated).problems == ["BH.oM.Demo.ProjectInfo.ProjectNumber: default differs: \"'n/a'\" != \"''\""] + +def test_a_property_missing_in_the_csharp_fails(tmp_path: Path): + """Checks that align reports a class whose property list differs because the C# does not have the PR's new property. + + Protects the align step by blocking a Python PR until the C# change is merged. + """ + base, head, generated = versions(tmp_path) + problems = align(base, head, generated).problems + assert len(problems) == 1 and problems[0].startswith("BH.oM.Demo.ProjectInfo: property names differs") + +def test_a_class_not_yet_in_the_csharp_or_not_yet_removed_fails(tmp_path: Path): + """Checks that align reports a new Python class missing from the C# and a removed Python class that the C# still declares. + + Protects the align step by tying the Python PR to the merge of the linked C# PR. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Mode.py").unlink() + (head_root / "Demo_oM" / "Fresh.py").write_text(module("Fresh", "BH.oM.Demo.Fresh", "BH.oM.Demo", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + generated = read_package(demo_package(tmp_path / "generated")) + assert align(base, read_package(head_root), generated).problems == [ + "BH.oM.Demo.Fresh: not found in the C#; merge and build the linked C# pull request first", + "BH.oM.Demo.Mode: removed in the Python, but the C# still declares it", + ] + +def test_command_line_align_with_a_generated_package(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs align exits 0 and prints the compared count when the generated package matches the PR, and exits 1 and prints the difference when it does not. + + Protects the align command that the PR checks workflow runs. + """ + demo_package(tmp_path / "base") + demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + demo_package(tmp_path / "same", ["ProjectNumber: str = ''"]) + demo_package(tmp_path / "other") + arguments = ["align", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema")] + + assert main([*arguments, "--generated-root", str(tmp_path / "same" / "bhom_schema")]) == 0 + assert "1 classes compared, 0 differences" in capsys.readouterr().out + + assert main([*arguments, "--generated-root", str(tmp_path / "other" / "bhom_schema")]) == 1 + out = capsys.readouterr().out + assert "difference: BH.oM.Demo.ProjectInfo: property names differs" in out and "first value: the C#, second: this PR" in out + +def test_nothing_to_compare_passes_without_running_the_generator(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs align exits 0 and reports nothing to compare, without needing the generator, when the PR changes no classes. + + Keeps PRs that do not touch classes from paying for a generator run. + """ + demo_package(tmp_path / "base") + demo_package(tmp_path / "head") + status = main(["align", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema"), + "--assemblies", str(tmp_path / "does-not-exist")]) + assert status == 0 and "nothing to compare" in capsys.readouterr().out + +def test_missing_generator_is_a_clear_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs align exits 2 with a clear message when the generator program is not found. + + Protects the PR checks from a silent pass when the generator is not built. + """ + demo_package(tmp_path / "base") + demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + (tmp_path / "dlls").mkdir() + status = main(["align", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema"), + "--assemblies", str(tmp_path / "dlls"), "--generator", str(tmp_path / "nowhere.dll")]) + assert status == 2 and "the generator was not found" in capsys.readouterr().err diff --git a/.ci/unit-tests/python/test_py2cs/test_linked_pr.py b/.ci/unit-tests/python/test_py2cs/test_linked_pr.py new file mode 100644 index 0000000..56acd51 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_linked_pr.py @@ -0,0 +1,372 @@ +"""The linked C# pull request code, run against an in-memory GitHub: branch names, idempotence, dry runs, closing, the REST calls.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import pytest + +from helpers import Recorder, csharp_tree, demo_package, mock_github, module, package, require_patcher +from py2cs.__main__ import main +from py2cs.ci import affected_repos, load_allowed, make_client +from py2cs.diff import diff_manifests +from py2cs.github import MockGitHub, MockRepository, GitHubError, RestClient +from py2cs.plan import make_plan +from py2cs.pr import MARKER, PythonPr, branch_name, close_linked, raise_pr +from py2cs.reader import read_package +from py2cs.report import render_report + +REPO = "BHoM/Demo_Toolkit" +PYTHON_REPO = "BHoM/BHoM_PythonSchema" +PR = PythonPr(PYTHON_REPO, 7, "https://github.com/BHoM/BHoM_PythonSchema/pull/7") + +def written_plan(tmp_path: Path, extra: list[str] | None = None, new_class: bool = False, remove_enum: bool = False) -> Path: + """Runs make_plan on the demo package (with the extra fields, a new class or a removed enum as asked), writes the plan folder the way py2cs plan does and returns that folder. + + The raise, close and offline command line tests use it as the input of py2cs pr. + """ + require_patcher() + repo_root = csharp_tree(tmp_path / "clones") + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head", extra if extra is not None else ["ProjectNumber: str = ''"]) + if new_class: + (head_root / "Demo_oM" / "Fresh.py").write_text(module("Fresh", "BH.oM.Demo.Fresh", "BH.oM.Demo", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + if remove_enum: + (head_root / "Demo_oM" / "Mode.py").unlink() + head = read_package(head_root) + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo_root, {REPO: ["Demo_oM"]}) + assert plan.errors == [] + + out = tmp_path / "plan" + for f in plan.files: + target = out / f.repo.replace("/", "__") / f.path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f.text, encoding="utf-8-sig" if f.bom else "utf-8", newline="") + (out / "plan.json").write_text(json.dumps(plan.summary()), encoding="utf-8") + (out / "reports").mkdir() + (out / "reports" / (REPO.replace("/", "__") + ".md")).write_text(render_report(plan, PR.reference, REPO), encoding="utf-8") + return out + +# ---------------------------------------------------------------- raising + +def test_branch_is_named_after_the_python_pr(): + """Checks that the linked branch is named python-schema/pr- after the Python PR. + + Protects the link that the status, close and sweep jobs use to find the C# PR. + """ + assert branch_name(7) == "python-schema/pr-7" + +def test_first_run_creates_branch_files_draft_pr_label_and_comment(tmp_path: Path): + """Checks that the first raise_pr run creates the branch, writes the planned file, opens a draft PR with the right title and Depends on text, and comments on the Python PR. + + Protects the linked-PR workflow's raise job. + """ + plan = written_plan(tmp_path, ["Count: int = 0"]) + remote = mock_github(REPO) + result = raise_pr(remote, plan, REPO, PR, python_client=remote) + + assert [c[0] for c in remote.calls] == ["create_branch", "put_file", "create_pull_request", "upsert_comment"] + pull = remote.repositories[REPO].pull_requests[0] + assert (pull.head, pull.base, pull.draft) == ("python-schema/pr-7", "develop", True) + assert pull.title == "Python schema #7: ProjectInfo" + assert pull.body.startswith("Depends on BHoM/BHoM_PythonSchema#7") + assert result.pull_request is not None and result.pull_request.url == "https://github.com/BHoM/Demo_Toolkit/pull/1" + comment = remote.repositories[PYTHON_REPO].comments[7][0] + assert MARKER in comment and "https://github.com/BHoM/Demo_Toolkit/pull/1" in comment + assert b"public virtual int Count" in remote.repositories[REPO].files[("python-schema/pr-7", "Demo_oM/AdministrativeInformation/ProjectInfo.cs")] + +def test_running_again_changes_nothing(tmp_path: Path): + """Checks that running raise_pr again with the same plan writes only the Python PR comment (updated in place) and opens no second PR. + + Protects the raise job from noise when the workflow re-runs. + """ + plan = written_plan(tmp_path) + remote = mock_github(REPO) + raise_pr(remote, plan, REPO, PR, python_client=remote) + remote.calls.clear() + + again = raise_pr(remote, plan, REPO, PR, python_client=remote) + assert [c[0] for c in remote.calls] == ["upsert_comment"], "same branch, same files, same PR: only the (updated in place) comment is written" + assert len(remote.repositories[REPO].pull_requests) == 1 + assert len(remote.repositories[PYTHON_REPO].comments[7]) == 1, "the comment is updated, not repeated" + assert any(a.kind == "unchanged" for a in again.actions) + +def test_a_changed_python_pr_updates_the_same_branch_and_pr(tmp_path: Path): + """Checks that a changed plan updates the same branch and the same draft PR instead of opening another. + + Protects the raise job when a developer pushes more commits to the Python PR. + """ + remote = mock_github(REPO) + raise_pr(remote, written_plan(tmp_path / "one", ["A: int = 0"]), REPO, PR, python_client=remote) + remote.calls.clear() + + raise_pr(remote, written_plan(tmp_path / "two", ["A: int = 0", "B: str = ''"]), REPO, PR, python_client=remote) + assert [c[0] for c in remote.calls] == ["put_file", "update_pull_request", "upsert_comment"] + assert len(remote.repositories[REPO].pull_requests) == 1 + assert "public virtual string B" in remote.repositories[REPO].files[("python-schema/pr-7", "Demo_oM/AdministrativeInformation/ProjectInfo.cs")].decode() + +def test_two_python_prs_get_two_branches(tmp_path: Path): + """Checks that two Python PRs get two separate branches and draft PRs in the C# repository. + + Protects the link between each Python PR and its C# PR. + """ + plan = written_plan(tmp_path) + remote = mock_github(REPO) + raise_pr(remote, plan, REPO, PythonPr(PYTHON_REPO, 7)) + raise_pr(remote, plan, REPO, PythonPr(PYTHON_REPO, 8)) + assert sorted(p.head for p in remote.repositories[REPO].pull_requests) == ["python-schema/pr-7", "python-schema/pr-8"] + +def test_breaking_changes_label_the_pull_request(tmp_path: Path): + """Checks that a plan with a breaking change puts the breaking-change label on the draft C# PR. + + Protects the label that tells reviewers a change may break users. + """ + plan = written_plan(tmp_path, remove_enum=True) + remote = mock_github(REPO) + raise_pr(remote, plan, REPO, PR) + pull = remote.repositories[REPO].pull_requests[0] + assert remote.repositories[REPO].labels[pull.number] == ["breaking-change"] + +def test_a_dry_run_writes_nothing_and_lists_what_it_would_do(tmp_path: Path): + """Checks that a dry run of raise_pr makes no GitHub calls and lists the actions it would do. + + Protects the preview of the raise job. + """ + plan = written_plan(tmp_path, new_class=True) + remote = mock_github(REPO) + result = raise_pr(remote, plan, REPO, PR, dry_run=True, python_client=remote) + + assert remote.calls == [] + kinds = [a.kind for a in result.actions] + assert kinds[:2] == ["create branch", "write file"] and "open draft pull request" in kinds and "comment on the Python pull request" in kinds + assert result.pull_request is None + +def test_nothing_is_done_for_a_repository_with_no_planned_files(tmp_path: Path): + """Checks that raise_pr skips a repository the plan has no files for and makes no GitHub calls. + + Protects the raise job's matrix, which runs once per affected repository. + """ + plan = written_plan(tmp_path) + remote = MockGitHub({"BHoM/Other": MockRepository()}) + result = raise_pr(remote, plan, "BHoM/Other", PR) + assert remote.calls == [] and result.actions[0].kind == "skip" + +def test_a_repository_the_app_cannot_reach_is_an_error(tmp_path: Path): + """Checks that raise_pr raises a GitHubError when the repository is not reachable with the token. + + Protects the raise job from silently dropping a C# change. + """ + plan = written_plan(tmp_path) + with pytest.raises(GitHubError, match="not found"): + raise_pr(MockGitHub({}), plan, REPO, PR) + +# ---------------------------------------------------------------- closing + +def test_closing_an_unmerged_python_pr_closes_the_linked_draft_with_a_comment(tmp_path: Path): + """Checks that close_linked closes the linked draft PR and comments that the Python PR was closed without merging when it was not merged. + + Protects the close job of the linked-PR workflow. + """ + remote = mock_github(REPO) + raise_pr(remote, written_plan(tmp_path), REPO, PR) + remote.calls.clear() + + close_linked(remote, REPO, PR, merged=False) + pull = remote.repositories[REPO].pull_requests[0] + assert pull.state == "closed" + assert "closed without merging" in remote.repositories[REPO].comments[pull.number][0] + +def test_a_merged_python_pr_only_comments(tmp_path: Path): + """Checks that close_linked leaves the linked C# PR open and only comments that the Python PR was merged. + + Protects the close job, since a person merges the C# PR. + """ + remote = mock_github(REPO) + raise_pr(remote, written_plan(tmp_path), REPO, PR) + close_linked(remote, REPO, PR, merged=True) + pull = remote.repositories[REPO].pull_requests[0] + assert pull.state == "open" and "was merged" in remote.repositories[REPO].comments[pull.number][0] + +def test_closing_with_no_linked_pr_does_nothing(): + """Checks that close_linked makes no changes and returns a skip action when no linked PR exists. + + Protects the close job for Python PRs that needed no C# change. + """ + remote = mock_github(REPO) + result = close_linked(remote, REPO, PR, merged=False) + assert remote.calls == [] and result.actions[0].kind == "skip" + +def test_closing_dry_run_writes_nothing(tmp_path: Path): + """Checks that a dry run of close_linked leaves the linked PR open and makes no GitHub calls. + + Protects the preview of the close job. + """ + remote = mock_github(REPO) + raise_pr(remote, written_plan(tmp_path), REPO, PR) + remote.calls.clear() + close_linked(remote, REPO, PR, merged=False, dry_run=True) + assert remote.calls == [] and remote.repositories[REPO].pull_requests[0].state == "open" + +# ---------------------------------------------------------------- allowed repositories + +def test_only_listed_repositories_may_get_pull_requests(tmp_path: Path): + """Checks that affected_repos accepts only repositories in the allowed listing (ignoring comments and blank lines), returns a problem for others, and uses the listing's spelling of the name. + + Protects the repos job, which decides where draft PRs may be raised. + """ + allowed = tmp_path / "allowed.txt" + allowed.write_text("# comment\n\nBHoM/Other_Toolkit\n", encoding="utf-8") + base = read_package(demo_package(tmp_path / "base")) + head = read_package(demo_package(tmp_path / "head", ["ProjectNumber: str = ''"])) + + repos, problems = affected_repos(base, head, load_allowed(allowed)) + assert repos == [] and len(problems) == 1 and "BHoM/Demo_Toolkit is not in the repository listing" in problems[0] + + allowed.write_text("bhom/demo_toolkit\n", encoding="utf-8") + # The spelling of the list wins, so write it as GitHub does + assert affected_repos(base, head, load_allowed(allowed)) == (["bhom/demo_toolkit"], []) + +def test_command_line_repos_prints_json_and_fails_for_a_repository_that_is_not_allowed(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs repos prints the affected repositories as JSON and exits 1 with a message when one is not in the allowed listing. + + Protects the repos job, which feeds the matrix of the raise job. + """ + demo_package(tmp_path / "base") + demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + allowed = tmp_path / "allowed.txt" + allowed.write_text("BHoM/Demo_Toolkit\n", encoding="utf-8") + arguments = ["repos", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema")] + + assert main([*arguments, "--allowed-repos", str(allowed)]) == 0 + assert json.loads(capsys.readouterr().out) == ["BHoM/Demo_Toolkit"] + + allowed.write_text("BHoM/Elsewhere\n", encoding="utf-8") + assert main([*arguments, "--allowed-repos", str(allowed)]) == 1 + assert "not in the repository listing" in capsys.readouterr().err + +def test_plan_only_repo_leaves_the_other_repositories_out(tmp_path: Path): + """Checks that make_plan restricted to one repository (matching case-insensitively) leaves the files of other repositories out. + + Protects the raise job, whose matrix plans one repository at a time. + """ + require_patcher() + repo_root = csharp_tree(tmp_path / "clones") + base = read_package(demo_package(tmp_path / "base")) + head = read_package(demo_package(tmp_path / "head", ["ProjectNumber: str = ''"])) + diff = diff_manifests(base, head) + assert make_plan(diff, head, base, lambda r: repo_root, None, None, "BHoM/Elsewhere").files == [] + assert len(make_plan(diff, head, base, lambda r: repo_root, None, None, "bhom/demo_toolkit").files) == 1 + +# ---------------------------------------------------------------- the command line, offline + +def test_command_line_dry_run_without_a_token_is_offline(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch): + """Checks that py2cs pr --dry-run with no token runs offline and prints what it would do (create the branch, open a draft PR) with exit code 0. + + Lets people preview the linked PR without GitHub access. + """ + plan = written_plan(tmp_path) + monkeypatch.delenv("PY2CS_CSHARP_TOKEN", raising=False) + status = main(["pr", "--plan-dir", str(plan), "--repo", REPO, "--python-repo", PYTHON_REPO, "--python-pr", "7", "--dry-run"]) + out = capsys.readouterr().out + assert status == 0 and "offline dry run" in out + assert "would create branch BHoM/Demo_Toolkit python-schema/pr-7 from develop" in out + assert "would open draft pull request" in out + +def test_command_line_without_a_token_and_not_a_dry_run_is_an_error(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch): + """Checks that py2cs pr without a token and without --dry-run exits with 2 and says no token is held. + + Protects the raise job from a silent pass when the app token is missing. + """ + plan = written_plan(tmp_path) + monkeypatch.delenv("PY2CS_CSHARP_TOKEN", raising=False) + assert main(["pr", "--plan-dir", str(plan), "--repo", REPO, "--python-repo", PYTHON_REPO, "--python-pr", "7"]) == 2 + assert "holds no token" in capsys.readouterr().err + +def test_make_client_reads_the_token_from_the_environment(monkeypatch: pytest.MonkeyPatch): + """Checks that make_client builds a RestClient from the token in the named environment variable and keeps the token out of the client's repr. + + Protects the token handling of the pr, close and status commands. + """ + monkeypatch.setenv("SOME_TOKEN", "ghs_secret") + client, offline = make_client("SOME_TOKEN") + assert isinstance(client, RestClient) and not offline + assert "ghs_secret" not in repr(client) + +# ---------------------------------------------------------------- the REST client + +def test_rest_client_calls_the_right_endpoints(): + """Checks that each RestClient method calls the right GitHub REST endpoint with the right body and the bearer token. + + Protects the GitHubClient that the pr, close and status commands use against the real GitHub. + """ + pull: dict[str, Any] = {"number": 3, "html_url": "https://github.com/o/r/pull/3", "head": {"ref": "python-schema/pr-7"}, "base": {"ref": "develop"}, "title": "t", "body": "b", "draft": True, "state": "open"} + content = base64.b64encode(b"hello").decode() + transport = Recorder({ + ("GET", "/repos/o/r"): (200, {"default_branch": "develop"}), + ("GET", "/repos/o/r/git/ref/heads/develop"): (200, {"object": {"sha": "abc"}}), + ("POST", "/repos/o/r/git/refs"): (201, {}), + ("GET", "/repos/o/r/contents/a%20b/File.cs?ref=python-schema%2Fpr-7"): (200, {"sha": "blob1", "content": content}), + ("PUT", "/repos/o/r/contents/a%20b/File.cs"): (200, {}), + ("GET", "/repos/o/r/pulls?state=open&head=o%3Apython-schema%2Fpr-7"): (200, [pull]), + ("POST", "/repos/o/r/pulls"): (201, pull), + ("PATCH", "/repos/o/r/pulls/3"): (200, pull), + ("POST", "/repos/o/r/issues/3/labels"): (200, []), + ("GET", "/repos/o/r/issues/3/comments?per_page=100"): (200, [{"id": 11, "body": f"{MARKER} old"}]), + ("PATCH", "/repos/o/r/issues/comments/11"): (200, {}), + }) + client = RestClient("tok", transport=transport) + + assert client.default_branch("o/r") == "develop" + assert client.branch_sha("o/r", "develop") == "abc" + assert client.branch_sha("o/r", "missing") is None + client.create_branch("o/r", "python-schema/pr-7", "abc") + found = client.get_file("o/r", "python-schema/pr-7", "a b/File.cs") + assert found is not None and (found.sha, found.content) == ("blob1", b"hello") + client.put_file("o/r", "python-schema/pr-7", "a b/File.cs", b"new", "message", "blob1") + existing = client.find_pull_request("o/r", "python-schema/pr-7") + assert existing is not None and (existing.number, existing.draft) == (3, True) + assert client.create_pull_request("o/r", "python-schema/pr-7", "develop", "t", "b", True).number == 3 + client.update_pull_request("o/r", 3, "t", "b") + client.add_labels("o/r", 3, ["breaking-change"]) + client.upsert_comment("o/r", 3, MARKER, f"{MARKER} new") + client.close_pull_request("o/r", 3) + + by_call = {(m, p): b for m, p, _, b in transport.requests} + assert by_call[("POST", "/repos/o/r/git/refs")] == {"ref": "refs/heads/python-schema/pr-7", "sha": "abc"} + put = by_call[("PUT", "/repos/o/r/contents/a%20b/File.cs")] + assert put == {"message": "message", "content": base64.b64encode(b"new").decode(), "branch": "python-schema/pr-7", "sha": "blob1"} + assert by_call[("POST", "/repos/o/r/pulls")] == {"title": "t", "head": "python-schema/pr-7", "base": "develop", "body": "b", "draft": True} + assert by_call[("PATCH", "/repos/o/r/issues/comments/11")] == {"body": f"{MARKER} new"}, "the existing comment is updated" + assert by_call[("PATCH", "/repos/o/r/pulls/3")] == {"state": "closed"}, "the last call to this pull request closed it" + assert all(h["Authorization"] == "Bearer tok" for _, _, h, _ in transport.requests) + +def test_rest_client_posts_a_new_comment_when_there_is_no_marker(): + """Checks that upsert_comment posts a new comment when no existing comment has the marker. + + Protects the Python PR comment that links to the draft C# PR. + """ + transport = Recorder({("GET", "/repos/o/r/issues/3/comments?per_page=100"): (200, [{"id": 1, "body": "someone else"}]), ("POST", "/repos/o/r/issues/3/comments"): (201, {})}) + RestClient("tok", transport=transport).upsert_comment("o/r", 3, MARKER, "text") + assert transport.requests[-1][:2] == ("POST", "/repos/o/r/issues/3/comments") + +def test_rest_errors_name_the_call_but_never_the_token(): + """Checks that a failed REST call raises a GitHubError that names the call and status and does not contain the token. + + Protects the workflow logs from leaking the app token. + """ + transport = Recorder({("GET", "/repos/o/r"): (403, {"message": "Resource not accessible by integration"})}) + with pytest.raises(GitHubError) as error: + RestClient("secret-token", transport=transport).default_branch("o/r") + assert "GET /repos/o/r failed with 403: Resource not accessible by integration" in str(error.value) + assert "secret-token" not in str(error.value) + +def test_a_client_without_a_token_is_refused(): + """Checks that RestClient raises a GitHubError when given an empty token. + + Protects the workflows from running unauthenticated. + """ + with pytest.raises(GitHubError): + RestClient("") diff --git a/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py b/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py new file mode 100644 index 0000000..b3381b5 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from helpers import cs, csharp_tree, demo_package, module, package, require_patcher +from py2cs.__main__ import main +from py2cs.diff import diff_manifests +from py2cs.locate import locate, place +from py2cs.plan import make_plan +from py2cs.reader import read_package +from py2cs.report import render_report +from py2cs.resolve import ResolveError, build_listing, load_listing, resolve_repo + +LISTING = Path(__file__).resolve().parents[2] / "github-fixtures" / "repo-listing.json" + + +def info(tmp_path: Path, clr: str, ns: str, kind: str = "class", asm: str = "Demo_oM", repo: str | None = "BHoM/Demo_Toolkit"): + """Builds a one-class package in a folder named from the assembly, class and repository and returns the manifest entry of that class. + + The locate, place and resolve tests use it to get a TypeInfo for a given name, namespace, kind, assembly and repository. + """ + name = clr.rsplit(".", 1)[-1] + pkg = package(tmp_path / f"pkg_{asm}_{name}_{repo is None}", {f"{asm}/{name}.py": module(name, clr, ns, asm, repo=repo, kind=kind)}) + return read_package(pkg)[clr] + + +# ---------------------------------------------------------------- locate + +def test_existing_file_is_found_although_the_folder_does_not_match_the_namespace(tmp_path: Path): + """Checks that locate finds the existing .cs file of a class by its declaration even when its folder does not match its namespace. + + Protects the plan stage, which patches the existing file and never works from the namespace. + """ + repo = csharp_tree(tmp_path) + found = locate(repo, info(tmp_path, "BH.oM.Demo.ProjectInfo", "BH.oM.Demo")) + assert found.path is not None and found.path.relative_to(repo).as_posix() == "Demo_oM/AdministrativeInformation/ProjectInfo.cs" + + +def test_locate_uses_the_namespace_to_choose_between_same_named_files(tmp_path: Path): + """Checks that locate uses the namespace to choose between two files with the same name. + + Protects the plan stage from patching the wrong class. + """ + repo = csharp_tree(tmp_path) + found = locate(repo, info(tmp_path, "BH.oM.Other.Line", "BH.oM.Other")) + assert found.path is not None and found.path.relative_to(repo).as_posix() == "Demo_oM/Other/Line.cs" + + +def test_locate_finds_a_type_declared_in_a_differently_named_file(tmp_path: Path): + """Checks that locate finds a class declared in a file with another name (several types in one file). + + Protects the plan stage for repositories that group classes. + """ + repo = csharp_tree(tmp_path) + found = locate(repo, info(tmp_path, "BH.oM.Demo.Beta", "BH.oM.Demo")) + assert found.path is not None and found.path.name == "Several.cs" + + +def test_locate_reports_missing_types_and_ignores_build_folders(tmp_path: Path): + """Checks that locate returns no path for a type that does not exist and ignores generated files in build folders such as obj. + + Protects the plan stage from treating a missing class as existing or patching build output. + """ + repo = csharp_tree(tmp_path) + assert locate(repo, info(tmp_path, "BH.oM.Demo.Nope", "BH.oM.Demo")).path is None + assert locate(repo, info(tmp_path, "BH.oM.Demo.Ghost", "BH.oM.Demo")).path is None + + +# ---------------------------------------------------------------- place + +def test_place_enum_next_to_sibling_enums(tmp_path: Path): + """Checks that place puts a new enum in the folder of the other enums of its assembly and gives the rule it used. + + Protects the path chosen for new C# files in the plan. + """ + repo = csharp_tree(tmp_path) + placement = place(repo, info(tmp_path, "BH.oM.Demo.Size", "BH.oM.Demo", kind="enum")) + assert placement.path == "Demo_oM/Enums/Size.cs" and "same kind" in placement.rule + + +def test_place_class_next_to_sibling_classes_of_the_namespace(tmp_path: Path): + """Checks that place puts a new class in the folder that holds the other classes of its namespace. + + Protects the path chosen for new C# files in the plan. + """ + repo = csharp_tree(tmp_path) + placement = place(repo, info(tmp_path, "BH.oM.Demo.Elements.Window", "BH.oM.Demo.Elements")) + assert placement.path == "Demo_oM/Elements/Window.cs" + + +def test_place_falls_back_to_namespace_folder_then_assembly_root(tmp_path: Path): + """Checks that place falls back to a folder named after the namespace and then to the assembly folder root when no sibling exists. + + Protects the path chosen for new C# files in the plan. + """ + repo = csharp_tree(tmp_path) + (repo / "Demo_oM" / "Loads").mkdir() + assert place(repo, info(tmp_path, "BH.oM.Demo.Loads.Point", "BH.oM.Demo.Loads")).path == "Demo_oM/Loads/Point.cs" + root = place(repo, info(tmp_path, "BH.oM.Demo.Brand.New", "BH.oM.Demo.Brand")) + assert root.path == "Demo_oM/New.cs" and "assembly root" in root.rule + + +def test_place_in_an_unknown_assembly_creates_a_folder_named_after_it(tmp_path: Path): + """Checks that place puts a class of a new assembly in a new folder named after the assembly. + + Protects the path chosen for new C# files of a new assembly. + """ + repo = csharp_tree(tmp_path) + assert place(repo, info(tmp_path, "BH.oM.Fresh.Thing", "BH.oM.Fresh", asm="Fresh_oM")).path == "Fresh_oM/Thing.cs" + + +# ---------------------------------------------------------------- resolve + +def test_repo_from_the_marker_is_validated_case_insensitively(tmp_path: Path): + """Checks that resolve_repo takes the repository from the marker, accepts URL or case differences against the listing and returns the listing's spelling. + + Protects the resolve stage that decides which C# repository a class belongs to. + """ + listing = {"BHoM/dKoP_Toolkit": ["dKoP_oM"], "BHoM/BHoM": ["Geometry_oM"]} + assert resolve_repo(info(tmp_path, "BH.oM.A.X", "BH.oM.A", repo="https://github.com/BHoM/dkop_toolkit"), listing).repo == "BHoM/dKoP_Toolkit" + assert resolve_repo(info(tmp_path, "BH.oM.A.Y", "BH.oM.A", repo="BHoM/BHoM")).source == "marker" + + +def test_repo_missing_from_the_listing_is_an_error(tmp_path: Path): + """Checks that resolve_repo raises a ResolveError mentioning the GitHub App when the marker names a repository that is not in the listing. + + Protects the plan from targeting a repository the app cannot write to. + """ + with pytest.raises(ResolveError, match="GitHub App"): + resolve_repo(info(tmp_path, "BH.oM.A.X", "BH.oM.A", repo="BHoM/Hidden"), {"BHoM/BHoM": []}) + + +def test_scan_fallback_finds_the_assembly_folder(tmp_path: Path): + """Checks that resolve_repo finds the repository by the assembly folder in the listing when the class has no repository in its marker. + + Protects the resolve stage for hand-written classes. + """ + listing = {"BHoM/BHoM": ["Geometry_oM"], "BHoM/Revit_Toolkit": ["Revit_oM"]} + found = resolve_repo(info(tmp_path, "BH.oM.Revit.X", "BH.oM.Revit", asm="Revit_oM", repo=None), listing) + assert (found.repo, found.source) == ("BHoM/Revit_Toolkit", "scan") + + +def test_scan_fallback_errors_are_clear(tmp_path: Path): + """Checks that resolve_repo raises a clear ResolveError when no repository, several repositories or no recorded repository apply. + + Protects the resolve stage from guessing a repository. + """ + with pytest.raises(ResolveError, match="no repository has a folder"): + resolve_repo(info(tmp_path, "BH.oM.A.X", "BH.oM.A", asm="Nowhere_oM", repo=None), {"BHoM/BHoM": ["Geometry_oM"]}) + with pytest.raises(ResolveError, match="several repositories"): + resolve_repo(info(tmp_path, "BH.oM.A.X", "BH.oM.A", asm="Dup_oM", repo=None), {"A/A": ["Dup_oM"], "B/B": ["dup_oM"]}) + with pytest.raises(ResolveError, match="no repo recorded"): + resolve_repo(info(tmp_path, "BH.oM.A.X", "BH.oM.A", repo=None)) + + +def test_recorded_listing_resolves_the_real_assemblies(tmp_path: Path): + """Checks that the recorded real repository listing resolves real assemblies (dKoP, Geometry, BHoM, Revit, Python, Adapter) to their repositories, also from a marker with different case. + + Protects the resolve stage against the real BHoM layout. + """ + listing = load_listing(LISTING) + for assembly, repo in [("dKoP_oM", "BHoM/dKoP_Toolkit"), ("Geometry_oM", "BHoM/BHoM"), ("BHoM", "BHoM/BHoM"), ("Revit_oM", "BHoM/Revit_Toolkit"), + ("Python_oM", "BHoM/Python_Toolkit"), ("Adapter_oM", "BHoM/BHoM_Adapter")]: + assert resolve_repo(info(tmp_path / assembly, f"BH.oM.X.{assembly}T", "BH.oM.X", asm=assembly, repo=None), listing).repo == repo + assert resolve_repo(info(tmp_path, "BH.oM.X.Y", "BH.oM.X", asm="dKoP_oM", repo="BHoM/dKop_Toolkit"), listing).repo == "BHoM/dKoP_Toolkit" + + +def test_listing_can_be_built_from_clones(tmp_path: Path): + """Checks that build_listing returns the assembly folders found in local clones for each repository. + + Protects the resolve stage, which uses the listing to find repositories. + """ + repo = csharp_tree(tmp_path) + assert build_listing({"BHoM/Demo_Toolkit": repo}) == {"BHoM/Demo_Toolkit": ["Demo_oM", "Other_oM"]} + + +# ---------------------------------------------------------------- plan + +def tree_hash(root: Path) -> str: + """Returns a SHA-256 over the relative path and content of every file under a folder. + + The plan tests use it to show that planning never writes into the C# checkouts. + """ + digest = hashlib.sha256() + for path in sorted(p for p in root.rglob("*") if p.is_file()): + digest.update(path.relative_to(root).as_posix().encode()) + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def test_plan_for_a_modified_new_and_removed_type(tmp_path: Path): + """Checks that make_plan patches the existing file of a modified class, drafts a new file for a new class, lists a removed class, writes nothing into the C# clones and gives a report starting with Depends on. + + Protects the plan stage of the linked-PR workflow. + """ + require_patcher() + clones = tmp_path / "clones" + repo = csharp_tree(clones) + before = tree_hash(clones) + + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + (head_root / "Demo_oM" / "Mode.py").unlink() + (head_root / "Demo_oM" / "Fresh.py").write_text(module("Fresh", "BH.oM.Demo.Fresh", "BH.oM.Demo", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + head = read_package(head_root) + + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo if r == "BHoM/Demo_Toolkit" else None, {"BHoM/Demo_Toolkit": ["Demo_oM"]}) + assert plan.errors == [] + by_type = {f.clr_name: f for f in plan.files} + modified = by_type["BH.oM.Demo.ProjectInfo"] + assert (modified.action, modified.path) == ("patch", "Demo_oM/AdministrativeInformation/ProjectInfo.cs") + assert modified.text == cs("BH.oM.Demo", "public class ProjectInfo : IdentityBase").replace(" {\n }", " {\n public virtual string ProjectNumber { get; set; } = \"\";\n }") + assert "public virtual string ProjectNumber { get; set; } = \"\";" in modified.text + new = by_type["BH.oM.Demo.Fresh"] + assert (new.action, new.path) == ("new", "Demo_oM/AdministrativeInformation/Fresh.cs") + assert plan.removed == [("BHoM/Demo_Toolkit", "BH.oM.Demo.Mode")] + assert tree_hash(clones) == before, "planning must never write into the C# checkouts" + + report = render_report(plan, "BHoM/BHoM_PythonSchema#7") + assert report.startswith("Depends on BHoM/BHoM_PythonSchema#7") + assert "## BHoM/Demo_Toolkit" in report and "property added: string ProjectNumber" in report and "BH.oM.Demo.Mode" in report + + +def test_plan_reports_problems_instead_of_guessing(tmp_path: Path): + """Checks that make_plan reports an error when there is no checkout of the repository or when it is not in the listing, and plans no files. + + Protects the plan stage from guessing where a class belongs. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + head = read_package(head_root) + no_clone = make_plan(diff_manifests(base, head), head, base, lambda r: None, None) + assert no_clone.files == [] and "no checkout of BHoM/Demo_Toolkit" in no_clone.errors[0] + not_installed = make_plan(diff_manifests(base, head), head, base, lambda r: None, {"BHoM/Elsewhere": []}) + assert "GitHub App" in not_installed.errors[0] + + +def test_command_line_plan_writes_files_into_the_output_only(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs plan writes the planned C# files, plan.json and the reports only into the output folder and leaves the clones unchanged. + + Protects the plan command that the raise job runs before it pushes anything. + """ + require_patcher() + clones = tmp_path / "clones" + csharp_tree(clones) + before = tree_hash(clones) + demo_package(tmp_path / "base") + demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + listing = tmp_path / "listing.json" + listing.write_text(json.dumps({"BHoM/Demo_Toolkit": ["Demo_oM"]})) + out = tmp_path / "out" + + status = main(["plan", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema"), + "--clones-parent", str(clones), "--listing", str(listing), "--python-pr", "BHoM/BHoM_PythonSchema#7", "--out", str(out)]) + assert status == 0 + assert (out / "BHoM__Demo_Toolkit" / "Demo_oM" / "AdministrativeInformation" / "ProjectInfo.cs").exists() + assert json.loads((out / "plan.json").read_text())["files"][0]["action"] == "patch" + assert "Depends on BHoM/BHoM_PythonSchema#7" in (out / "report.md").read_text() + assert tree_hash(clones) == before + assert "1 files planned" in capsys.readouterr().out + + +def test_command_line_diff_resolve_and_manifest(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Checks that py2cs diff, manifest and resolve run on a small package and write or print the expected output. + + Protects the command line that the workflows call. + """ + demo_package(tmp_path / "base") + demo_package(tmp_path / "head", ["ProjectNumber: str = ''"]) + assert main(["diff", "--base-root", str(tmp_path / "base" / "bhom_schema"), "--head-root", str(tmp_path / "head" / "bhom_schema"), "--out", str(tmp_path / "d.json")]) == 0 + assert json.loads((tmp_path / "d.json").read_text())["changes"][0]["kind"] == "modified" + assert main(["manifest", "--root", str(tmp_path / "head" / "bhom_schema"), "--out", str(tmp_path / "m.json")]) == 0 + capsys.readouterr() + assert main(["resolve", "--root", str(tmp_path / "head" / "bhom_schema")]) == 0 + assert "BH.oM.Demo.ProjectInfo\tBHoM/Demo_Toolkit\tmarker" in capsys.readouterr().out diff --git a/.ci/unit-tests/python/test_py2cs/test_notes.py b/.ci/unit-tests/python/test_py2cs/test_notes.py new file mode 100644 index 0000000..b6151e5 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_notes.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import demo_package, module +from py2cs.__main__ import main + + +def run_notes(tmp_path: Path, extra_fields: list[str], remove_mode: bool = False, first: bool = False) -> str: + """Runs the notes command on a demo package changed against another and returns the release notes text. + + The tests of the release notes use it. + """ + base = demo_package(tmp_path / "base") + head = demo_package(tmp_path / "head", extra_fields) + if remove_mode: + (head / "Demo_oM" / "Mode.py").unlink() + (head / "Demo_oM" / "Fresh.py").write_text(module("Fresh", "BH.oM.Demo.Fresh", "BH.oM.Demo", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + + out = tmp_path / "notes.md" + arguments = ["notes", "--head-root", str(head), "--version", "10.1.0", "--out", str(out)] + if not first: + arguments += ["--base-root", str(base), "--previous", "v10.0.0"] + + assert main(arguments) == 0 + return out.read_text(encoding="utf-8") + + +def test_notes_with_no_change_say_none_are_breaking(tmp_path: Path): + """Checks that notes for an unchanged schema name the version and the previous release and say there are no breaking changes. + + Protects the Breaking changes section from being left out when nothing breaks. + """ + notes = run_notes(tmp_path, []) + assert notes.startswith("# BHoM schema 10.1.0") + assert "since v10.0.0" in notes + assert "## Breaking changes\n\nNone." in notes + assert "## Added classes" not in notes + + +def test_notes_list_added_removed_and_breaking_changes(tmp_path: Path): + """Checks that notes list a removed class as breaking, an added class under Added classes, and an added property under Changed classes. + + Protects what a Python user reads before upgrading. + """ + notes = run_notes(tmp_path, ["ProjectNumber: str = ''"], remove_mode=True) + breaking = notes.split("## Breaking changes")[1].split("## ")[0] + assert "`BH.oM.Demo.Mode`: type removed" in breaking + assert "## Added classes\n\n- `BH.oM.Demo.Fresh`" in notes + assert "## Removed classes\n\n- `BH.oM.Demo.Mode`" in notes + assert " - property added: string ProjectNumber" in notes + + +def test_notes_for_a_first_release_list_every_class_as_added(tmp_path: Path): + """Checks that notes without a previous release say it is the first release and list every class as added. + + Protects the first release, which has nothing to compare with. + """ + notes = run_notes(tmp_path, [], first=True) + assert "(the first release)" in notes + assert "- `BH.oM.Demo.ProjectInfo`" in notes.split("## Added classes")[1] diff --git a/.ci/unit-tests/python/test_py2cs/test_patch_changes.py b/.ci/unit-tests/python/test_py2cs/test_patch_changes.py new file mode 100644 index 0000000..36e0d6d --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_patch_changes.py @@ -0,0 +1,150 @@ +"""The change list py2cs sends to the C# patcher, and a run of the real patcher on a copy of a real file.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from helpers import module, package, require_patcher +from py2cs.patch import build_patch, run_patcher +from py2cs.reader import read_package + +PATCHER_INPUTS = Path(__file__).resolve().parents[2] / "golden" / "patcher" + +def changes_between(tmp_path: Path, before: list[str], after: list[str], **marker: Any) -> tuple[list[dict[str, Any]], list[str], list[str]]: + """Builds two versions of a Demo_oM class Thing from the two bodies and returns the changes, usings and notes that build_patch produces between them. + + The patch change tests use it to see the change list that py2cs sends to the C# patcher. + """ + def version(name: str, body: list[str]): + """Reads a package holding IdentityBase, Other and Thing (with the given body or extra fields) and returns its manifest. + + Used by changes_between for its before and after versions. + """ + return read_package(package(tmp_path / name, { + "Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Other.py": module("Other", "BH.oM.Geometry.Other", "BH.oM.Geometry", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", body=body, **marker)})) + + base, head = version("base", before), version("head", after) + patch = build_patch(Path("Thing.cs"), base["BH.oM.Demo.Thing"], head["BH.oM.Demo.Thing"], head) + return patch.changes, patch.usings, patch.notes + +def test_added_properties_name_their_existing_neighbour(tmp_path: Path): + """Checks that added properties become addProperty changes that name the existing property they follow, with initialiser, description and quantity attributes, and that a property with no default adds a note and the needed usings. + + Protects the member-level change list that the patcher applies. + """ + changes, usings, notes = changes_between(tmp_path, ["A: int = 0", "B: int = 0"], [ + "First: str = ''", "A: int = 0", "Mid: Annotated[float, Quantity('Length', 'm')] = Field(default=1.5, description='Middle.')", "B: int = 0", "Last: str"]) + assert changes == [ + {"kind": "addProperty", "name": "First", "type": "string", "initialiser": '""', "add": [], "after": None}, + {"kind": "addProperty", "name": "Mid", "type": "double", "initialiser": "1.5", "add": ['Description("Middle.")', "Length"], "after": "A"}, + {"kind": "addProperty", "name": "Last", "type": "string", "initialiser": None, "add": [], "after": "B"}, + ] + assert usings == ["BH.oM.Quantities.Attributes", "System.ComponentModel"] + assert notes == ["Last has no default in Python, so it was added without an initial value"] + +def test_changed_and_removed_properties(tmp_path: Path): + """Checks that a changed type, default, description or quantity, and a removed property, become the matching change entries and that an untranslatable default adds a note. + + Protects the member-level change list that the patcher applies. + """ + changes, _, notes = changes_between(tmp_path, + ["A: int = 0", "B: str = Field(default='', description='Old.')", "C: Annotated[float, Quantity('Length', 'm')] = 0.0", "D: int = 0", "E: str = ''"], + ["A: float = 0.0", "B: str = Field(default='x', description='New.')", "C: Annotated[float, Quantity('Area', 'm2'), DisplayText('Area')] = 0.0", "E: str = odd()"]) + assert changes == [ + {"kind": "setPropertyType", "name": "A", "type": "double"}, + {"kind": "setPropertyInitialiser", "name": "A", "initialiser": "0.0"}, + {"kind": "setPropertyInitialiser", "name": "B", "initialiser": '"x"'}, + {"kind": "setDescription", "name": "B", "description": '"New."'}, + {"kind": "setAttributes", "name": "C", "remove": ["Length"], "add": ["Area", 'DisplayText("Area")']}, + {"kind": "removeProperty", "name": "D"}, + ] + assert notes == ["the default odd() of E could not be translated to C#; set it by hand"] + +def test_class_description_and_bases(tmp_path: Path): + """Checks that a changed class description and changed bases become setDescription and setBases changes and that only the added base adds a using. + + Protects the member-level change list that the patcher applies. + """ + before = read_package(package(tmp_path / "base", { + "Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Other.py": module("Other", "BH.oM.Geometry.Other", "BH.oM.Geometry", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, IdentityBase", doc="Old.", + imports=["from bhom_schema.Demo_oM.IdentityBase import IdentityBase"])})) + after = read_package(package(tmp_path / "head", { + "Demo_oM/IdentityBase.py": module("IdentityBase", "BH.oM.Demo.IdentityBase", "BH.oM.Demo", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Other.py": module("Other", "BH.oM.Geometry.Other", "BH.oM.Geometry", "Demo_oM", kind="interface", bases="ABC", imports=["from abc import ABC"]), + "Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", bases="SchemaModel, Other", doc="New.", + imports=["from bhom_schema.Demo_oM.Other import Other"])})) + patch = build_patch(Path("Thing.cs"), before["BH.oM.Demo.Thing"], after["BH.oM.Demo.Thing"], after) + assert patch.changes == [ + {"kind": "setDescription", "description": '"New."'}, + {"kind": "setBases", "remove": ["IdentityBase"], "add": ["Other"]}, + ] + assert patch.usings == ["BH.oM.Geometry", "System.ComponentModel"], "only the added base needs a using" + +def test_enum_members(tmp_path: Path): + """Checks that added, renumbered and removed enum members become addEnumMember, setEnumValue and removeEnumMember changes. + + Protects the member-level change list that the patcher applies to enums. + """ + enum = {"kind": "enum", "bases": "Enum", "imports": ["from enum import Enum"]} + changes, _, _ = changes_between(tmp_path, ["A = 1", "B = 2", "C = 3"], ["A = 1", "# Brand new.", "New = 4", "B = 5"], **enum) + assert changes == [ + {"kind": "addEnumMember", "name": "New", "value": "4", "description": '"Brand new."', "after": "A"}, + {"kind": "setEnumValue", "name": "B", "value": "5"}, + {"kind": "removeEnumMember", "name": "C"}, + ] + +def test_kind_and_generic_changes_are_left_to_a_person(tmp_path: Path): + """Checks that an unchanged class gives no notes and that a change of kind gives no change entries and a note to change the declaration by hand. + + Protects the patcher from editing declarations that only a person should change. + """ + _, _, notes = changes_between(tmp_path, ["A: int = 0"], ["A: int = 0"]) + assert notes == [] + before = read_package(package(tmp_path / "b", {"Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM")})) + after = read_package(package(tmp_path / "a", {"Demo_oM/Thing.py": module("Thing", "BH.oM.Demo.Thing", "BH.oM.Demo", "Demo_oM", kind="abstract", bases="SchemaModel, ABC", + imports=["from abc import ABC"])})) + patch = build_patch(Path("Thing.cs"), before["BH.oM.Demo.Thing"], after["BH.oM.Demo.Thing"], after) + assert patch.changes == [] and patch.notes == ["the kind changed from class to abstract; change the declaration by hand"] + +def test_the_real_patcher_changes_only_the_new_lines(tmp_path: Path): + """Checks that running the real C# patcher on a copy of a real file adds only the new property (with its description and using) and leaves the input file unchanged. + + Protects the Roslyn patcher round trip behind the plan stage. + """ + require_patcher() + original = PATCHER_INPUTS / "ProjectIdentification.cs" + copy = tmp_path / "ProjectIdentification.cs" + shutil.copyfile(original, copy) + + def project_identification(root: Path, extra: list[str]): + """Builds a package with the dKoP IdKoPObject interface and ProjectIdentification class (plus any extra fields) and returns its manifest. + + The real patcher test uses it for the base and head versions. + """ + return read_package(package(root, { + "dKoP_oM/IdKoPObject.py": module("IdKoPObject", "BH.oM.dKoP.IdKoPObject", "BH.oM.dKoP", "dKoP_oM", repo="BHoM/dKoP_Toolkit", kind="interface", bases="ABC", + imports=["from abc import ABC"]), + "dKoP_oM/ProjectIdentification.py": module( + "ProjectIdentification", "BH.oM.dKoP.ProjectIdentification", "BH.oM.dKoP", "dKoP_oM", repo="BHoM/dKoP_Toolkit", bases="SchemaModel, IdKoPObject", + imports=["from bhom_schema.dKoP_oM.IdKoPObject import IdKoPObject"], + body=["ProjectReference: str = ''", "ProjectName: str = ''", "ProjectLocation: str = ''", "AdditionalReferences: str = ''", *extra])})) + + base = project_identification(tmp_path / "base", []) + head = project_identification(tmp_path / "head", ["ProjectNumber: str = Field('', description='The project number.')"]) + clr = "BH.oM.dKoP.ProjectIdentification" + [result] = run_patcher([build_patch(copy, base[clr], head[clr], head)]) + + source = original.read_bytes().decode("utf-8-sig") + nl = "\r\n" if "\r\n" in source else "\n" + anchor = ' public virtual string AdditionalReferences { get; set; } = "";' + nl + added = nl + ' [Description("The project number.")]' + nl + ' public virtual string ProjectNumber { get; set; } = "";' + nl + expected = source.replace(anchor, anchor + added).replace("using System.Text;" + nl, "using System.Text;" + nl + "using System.ComponentModel;" + nl) + assert result.error is None and result.notes == [] + assert result.text == expected + assert copy.read_bytes() == original.read_bytes(), "the input file is only read" diff --git a/.ci/unit-tests/python/test_py2cs/test_reader_and_diff.py b/.ci/unit-tests/python/test_py2cs/test_reader_and_diff.py new file mode 100644 index 0000000..74d5842 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_reader_and_diff.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from helpers import demo_package, module, package +from py2cs.diff import diff_manifests +from py2cs.model import dump_manifest, load_manifest +from py2cs.reader import read_package + + +def test_reads_interface_class_and_enum(tmp_path: Path): + """Checks that read_package reads an interface, a class and an enum with kind, bases, namespace, assembly, repository, path, properties, defaults and enum members. + + Protects the reader stage, which turns the Python into the manifest that diff and plan use. + """ + manifest = read_package(demo_package(tmp_path)) + assert sorted(manifest) == ["BH.oM.Demo.IdentityBase", "BH.oM.Demo.Mode", "BH.oM.Demo.ProjectInfo"] + + info = manifest["BH.oM.Demo.ProjectInfo"] + assert info.kind == "class" and info.bases == ["BH.oM.Demo.IdentityBase"] + assert (info.namespace, info.assembly, info.repo) == ("BH.oM.Demo", "Demo_oM", "BHoM/Demo_Toolkit") + assert info.relative_path == "Demo_oM/ProjectInfo.py" + assert [(p.name, p.clr_type, p.has_default, p.default) for p in info.properties] == [ + ("Reference", "string", True, "''"), ("Name", "string", True, "''")] + + mode = manifest["BH.oM.Demo.Mode"] + assert [(m.name, m.value, m.description) for m in mode.members] == [("None", "0", None), ("Fast", "1", "Faster.")] + + +def test_reads_every_annotation_shape(tmp_path: Path): + """Checks that the reader maps every annotation shape (generics, collections, tuple, nullable, enum, alias, datetime, decimal, UUID, ClrType override, quantity, display text, description) to the right C# type and default. + + Protects the type mapping of the reader. + """ + pkg = package(tmp_path, { + "A_oM/Target.py": module("Target", "BH.oM.A.Target", "BH.oM.A", "A_oM", repo="BHoM/A"), + "A_oM/Kind.py": module("Kind", "BH.oM.A.Kind", "BH.oM.A", "A_oM", repo="BHoM/A", kind="enum", bases="Enum", imports=["from enum import Enum"], body=["One = 1"]), + "A_oM/Thing_1.py": module( + "Thing_1", "BH.oM.A.Thing`1", "BH.oM.A", "A_oM", repo="BHoM/A", type_params="[T: Target]", + marker_extra=["'generic_params': {'T': ['BH.oM.A.Target']}"], + imports=["from bhom_schema.A_oM.Target import Target", "from bhom_schema.A_oM.Kind import Kind", "from bhom_schema.A_oM.Target import Target as OtherTarget", + "from datetime import datetime", "from decimal import Decimal", "from uuid import UUID", "from collections.abc import Callable"], + body=[ + "Items: list[T] = Field(default_factory=list[T])", + "Count: Annotated[int, ClrType('long')] = 3", + "Names: set[str] = Field(default_factory=set)", + "Lookup: dict[str, float] = Field(default_factory=dict)", + "Pair: tuple[int, str] | None = None", + "Maybe: int | None = None", + "Which: Kind = Kind.One", + "MaybeKind: Kind | None = None", + "Own: Thing_1[Target] | None = None", + "Aliased: OtherTarget | None = None", + "When: datetime = Field(default_factory=datetime.now)", + "Cost: Decimal = Decimal('1.5')", + "Key: UUID = UUID(int=0)", + "Callback: Annotated[Callable[..., Any] | None, ClrType('System.Func')] = None", + "Span: Annotated[float, Quantity('Length', 'm'), DisplayText('Span')] = Field(default=1.5, description='Distance.')", + "Matrix: list[list[int]] = Field(default_factory=list)", + ]), + }) + info = read_package(pkg)["BH.oM.A.Thing`1"] + types = {p.name: p.clr_type for p in info.properties} + assert info.name == "BH.oM.A.Thing" + assert info.generic_params[0].name == "T" and info.generic_params[0].constraints == ["BH.oM.A.Target"] + assert types["Items"] == "System.Collections.Generic.List" + assert types["Count"] == "long" + assert types["Names"] == "System.Collections.Generic.HashSet" + assert types["Lookup"] == "System.Collections.Generic.Dictionary" + assert types["Pair"] == "System.Tuple" + assert types["Maybe"] == "int?" + assert types["Which"] == "BH.oM.A.Kind" and types["MaybeKind"] == "BH.oM.A.Kind?" + assert types["Own"] == "BH.oM.A.Thing" + assert types["Aliased"] == "BH.oM.A.Target" + assert types["When"] == "System.DateTime" and types["Cost"] == "decimal" and types["Key"] == "System.Guid" + assert types["Callback"] == "System.Func" + assert types["Matrix"] == "System.Collections.Generic.List>" + span = next(p for p in info.properties if p.name == "Span") + assert span.quantity == ("Length", "m") and span.display_text == "Span" and span.description == "Distance." and span.default == "1.5" + items = next(p for p in info.properties if p.name == "Items") + assert items.default == " list[T]" + + +def test_manifest_round_trips_through_json(tmp_path: Path): + """Checks that a manifest written with dump_manifest and loaded with load_manifest equals the original. + + Protects the manifest file that the py2cs commands pass between workflow steps. + """ + manifest = read_package(demo_package(tmp_path)) + dump_manifest(manifest, tmp_path / "m.json") + again = load_manifest(tmp_path / "m.json") + assert again == manifest + + +def test_unsupported_annotation_is_reported(tmp_path: Path): + """Checks that an unsupported annotation (a union of two types) makes read_package raise a ValueError. + + Protects the reader from silently dropping a field. + """ + pkg = package(tmp_path, {"A_oM/Bad.py": module("Bad", "BH.oM.A.Bad", "BH.oM.A", "A_oM", body=["Value: int | str = 1"])}) + with pytest.raises(ValueError): + read_package(pkg) + + +# ---------------------------------------------------------------- diff + +def changed(tmp_path: Path, extra: list[str] | None = None, **edits: str): + """Builds the demo package as base and as head (with extra fields and with whole modules replaced by the keyword arguments) and returns both manifests. + + The diff tests use it to produce two versions of the package. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head", extra or []) + for relative, source in edits.items(): + (head_root / relative.replace("__", "/")).write_text(source, encoding="utf-8", newline="\n") + return base, read_package(head_root) + + +def test_diff_of_identical_packages_is_empty(tmp_path: Path): + """Checks that diff_manifests of two identical packages is empty. + + Protects the diff stage from false changes that would raise unneeded C# PRs. + """ + base, head = changed(tmp_path) + assert not diff_manifests(base, head) + + +def test_diff_property_added(tmp_path: Path): + """Checks that an added property gives one modified change with the detail property added. + + Protects the diff stage, which decides what the C# PR changes. + """ + base, head = changed(tmp_path, ["ProjectNumber: str = ''"]) + diff = diff_manifests(base, head) + assert [(c.clr_name, c.kind, c.details) for c in diff.changes] == [("BH.oM.Demo.ProjectInfo", "modified", ["property added: string ProjectNumber"])] + + +def test_diff_added_removed_and_changed(tmp_path: Path): + """Checks that diff_manifests reports an added class, a removed class and a modified class with removed and added properties, a type change, a default change and a base change. + + Protects the diff stage, which decides what the C# PR changes. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Mode.py").unlink() + (head_root / "Demo_oM" / "Fresh.py").write_text(module("Fresh", "BH.oM.Demo.Fresh", "BH.oM.Demo", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + (head_root / "Demo_oM" / "ProjectInfo.py").write_text(module( + "ProjectInfo", "BH.oM.Demo.ProjectInfo", "BH.oM.Demo", "Demo_oM", bases="SchemaModel", + body=["Reference: int = 0", "Extra: str = Field(default='x', description='new')"]), encoding="utf-8") + diff = diff_manifests(base, read_package(head_root)) + by_name = {c.clr_name: c for c in diff.changes} + assert by_name["BH.oM.Demo.Fresh"].kind == "added" + assert by_name["BH.oM.Demo.Mode"].kind == "removed" + details = by_name["BH.oM.Demo.ProjectInfo"].details + assert "property removed: string Name" in details + assert "property added: string Extra" in details + assert any(d.startswith("property Reference type changed from string to int") for d in details) + assert any(d.startswith("property Reference default changed") for d in details) + assert any(d.startswith("bases changed") for d in details) + + +def test_diff_enum_members(tmp_path: Path): + """Checks that diff_manifests reports a changed enum value and an added enum member. + + Protects the diff stage for enums. + """ + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Mode.py").write_text(module( + "Mode", "BH.oM.Demo.Mode", "BH.oM.Demo", "Demo_oM", kind="enum", bases="Enum", imports=["from enum import Enum"], + marker_extra=["'renamed_members': {'None_': 'None'}"], body=["None_ = 0", "Fast = 5", "Slow = 2"]), encoding="utf-8") + details = diff_manifests(base, read_package(head_root)).changes[0].details + assert details == ["enum member Fast value changed from 1 to 5", "enum member added: Slow = 2"] diff --git a/.ci/unit-tests/python/test_py2cs/test_status.py b/.ci/unit-tests/python/test_py2cs/test_status.py new file mode 100644 index 0000000..8837926 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_status.py @@ -0,0 +1,207 @@ +"""The commit status that says whether the linked C# pull request of a Python PR is merged.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from helpers import Recorder, mock_github +from py2cs.__main__ import main +from py2cs.github import MockGitHub, PullRequest, RestClient +from py2cs.pr import branch_name +from py2cs.status import CONTEXT, linked_status, update_status + +REPO = "BHoM/Demo_Toolkit" +OTHER = "BHoM/Other_Toolkit" +PYTHON_REPO = "BHoM/BHoM_PythonSchema" +SHA = "abc123" + +def linked(remote: MockGitHub, repo: str, number: int = 7, state: str = "open", merged: bool = False) -> PullRequest: + """Creates a draft PR on the linked branch of a Python PR in the given repository and sets its state and merged flag; returns it. + + The status tests use it to stand for a linked C# PR in each state. + """ + pull = remote.create_pull_request(repo, branch_name(number), "develop", "title", "body", True) + pull.state, pull.merged = state, merged + return pull + +def test_an_open_linked_draft_is_pending(): + """Checks that an open linked draft PR gives a pending status that names the PR and links to it. + + Protects the commit status that tells reviewers the C# PR is not merged yet. + """ + remote = mock_github(REPO) + pull = linked(remote, REPO) + status = linked_status(remote, [REPO], 7) + assert status is not None + assert (status.state, status.description, status.url) == ("pending", f"Waiting for {REPO}#{pull.number} to be merged", pull.url) + +def test_a_merged_linked_pr_is_success(): + """Checks that a merged linked PR gives a success status. + + Protects the commit status that allows the Python PR to merge once the C# is merged. + """ + remote = mock_github(REPO) + linked(remote, REPO, state="closed", merged=True) + status = linked_status(remote, [REPO], 7) + assert status is not None and (status.state, status.description) == ("success", f"Merged: {REPO}#1") + +def test_a_linked_pr_closed_without_merging_is_a_failure(): + """Checks that a linked PR closed without merging gives a failure status. + + Protects the commit status from passing a Python PR whose C# was abandoned. + """ + remote = mock_github(REPO) + linked(remote, REPO, state="closed", merged=False) + status = linked_status(remote, [REPO], 7) + assert status is not None and (status.state, status.description) == ("failure", f"{REPO}#1 was closed without merging") + +def test_no_linked_pr_means_no_status(): + """Checks that linked_status returns nothing when no linked PR exists, including when only another Python PR has a draft. + + Protects the status job from reporting on unrelated PRs. + """ + assert linked_status(mock_github(REPO), [REPO], 7) is None + remote = mock_github(REPO) + linked(remote, REPO, number=8) + assert linked_status(remote, [REPO], 7) is None, "another Python PR's draft is not this one's" + +def test_several_repositories_are_combined(): + """Checks that the statuses of several repositories combine into pending if any is open, success if all are merged and failure if any was closed without merging. + + Protects the status of a Python PR that changes classes of several C# repositories. + """ + remote = mock_github(REPO, OTHER) + linked(remote, REPO, state="closed", merged=True) + linked(remote, OTHER) + status = linked_status(remote, [REPO, OTHER], 7) + assert status is not None and status.state == "pending" and OTHER in status.description + + remote = mock_github(REPO, OTHER) + linked(remote, REPO, state="closed", merged=True) + linked(remote, OTHER, state="closed", merged=True) + status = linked_status(remote, [REPO, OTHER], 7) + assert status is not None and status.state == "success" + + remote = mock_github(REPO, OTHER) + linked(remote, REPO, state="closed", merged=True) + linked(remote, OTHER, state="closed", merged=False) + status = linked_status(remote, [REPO, OTHER], 7) + assert status is not None and status.state == "failure" + +def test_the_newest_pr_of_the_branch_counts(): + """Checks that an abandoned draft followed by a new one for the same Python PR gives pending, not failure. + + Protects the status when a developer reopens the linked work. + """ + remote = mock_github(REPO) + linked(remote, REPO, state="closed", merged=False) + newer = linked(remote, REPO) + status = linked_status(remote, [REPO], 7) + assert status is not None and status.state == "pending" and f"#{newer.number}" in status.description + +def test_the_status_is_written_on_the_head_commit_of_the_python_pr(): + """Checks that update_status writes the status with the right context on the head commit of the Python PR. + + Protects the status job of the linked-PR workflow. + """ + remote = mock_github(REPO) + linked(remote, REPO, state="closed", merged=True) + update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA) + assert remote.repositories[PYTHON_REPO].statuses[(SHA, CONTEXT)][0] == "success" + assert remote.calls[-1] == ("set_commit_status", PYTHON_REPO, SHA, "success") + +def test_nothing_is_written_without_a_linked_pr_or_on_a_dry_run(): + """Checks that update_status writes no status when there is no linked PR, and also none on a dry run, which still returns the status. + + Protects the status job from noise and the dry-run preview. + """ + remote = mock_github(REPO) + assert update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA) is None + assert not any(c[0] == "set_commit_status" for c in remote.calls) + + linked(remote, REPO) + remote.calls.clear() + status = update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA, dry_run=True) + assert status is not None and status.state == "pending" and remote.calls == [] + +def test_a_python_pr_that_needs_no_csharp_change_is_success(): + """Checks that update_status sets a success status saying no C# change is needed when no_change is given. + + Protects the status job for Python PRs that need no linked C# PR. + """ + remote = mock_github(REPO) + status = update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA, no_change=True) + assert status is not None and status.description == "No C# change is needed" + assert remote.repositories[PYTHON_REPO].statuses[(SHA, CONTEXT)][0] == "success" + +def test_a_new_status_replaces_the_old_one_for_the_same_commit(): + """Checks that a new status of the same context on the same commit replaces the old one, from pending to success. + + Protects the status job when the C# PR is merged later. + """ + remote = mock_github(REPO) + pull = linked(remote, REPO) + update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA) + assert remote.repositories[PYTHON_REPO].statuses[(SHA, CONTEXT)][0] == "pending" + + pull.state, pull.merged = "closed", True + update_status(remote, remote, [REPO], PYTHON_REPO, 7, SHA) + assert remote.repositories[PYTHON_REPO].statuses[(SHA, CONTEXT)][0] == "success" + assert len(remote.repositories[PYTHON_REPO].statuses) == 1 + +# ---------------------------------------------------------------- the command line + +def test_command_line_dry_run_without_a_token_is_offline(tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch): + """Checks that py2cs status --dry-run with no token runs offline and prints the status it would set, including with --no-csharp-change. + + Lets people preview the status command without GitHub access. + """ + monkeypatch.delenv("PY2CS_PYTHON_TOKEN", raising=False) + repos = tmp_path / "repos.txt" + repos.write_text(f"{REPO}\n", encoding="utf-8") + status = main(["status", "--python-repo", PYTHON_REPO, "--python-pr", "7", "--sha", SHA, "--repos-file", str(repos), "--dry-run"]) + out = capsys.readouterr().out + assert status == 0 and "offline dry run" in out and "no linked C# pull request: no status" in out + + assert main(["status", "--python-repo", PYTHON_REPO, "--python-pr", "7", "--sha", SHA, "--repos-file", str(repos), "--dry-run", "--no-csharp-change"]) == 0 + assert "would set the status success: No C# change is needed" in capsys.readouterr().out + +# ---------------------------------------------------------------- the REST calls + +def pull_json(number: int, state: str, merged_at: str | None) -> dict[str, object]: + """Builds the JSON of a pull request as the GitHub API returns it, for the given number, state and merged_at. + + The REST client tests use it to make canned answers. + """ + return {"number": number, "html_url": f"https://github.com/o/r/pull/{number}", "head": {"ref": "python-schema/pr-7"}, "base": {"ref": "develop"}, + "state": state, "draft": False, "merged_at": merged_at} + +def test_rest_client_finds_the_newest_pr_of_a_branch_in_any_state_and_knows_if_it_merged(): + """Checks that find_linked_pull_request returns the newest PR of the branch in any state, knows whether it merged, and returns nothing when there is none. + + Protects the GitHubClient that the status job and the sweep use. + """ + path = "/repos/o/r/pulls?state=all&sort=created&direction=desc&head=o%3Apython-schema%2Fpr-7" + transport = Recorder({("GET", path): (200, [pull_json(5, "closed", "2026-10-29T10:00:00Z"), pull_json(3, "closed", None)])}) + found = RestClient("tok", transport=transport).find_linked_pull_request("o/r", "python-schema/pr-7") + assert found is not None and (found.number, found.state, found.merged) == (5, "closed", True) + + transport = Recorder({("GET", path): (200, [pull_json(3, "closed", None)])}) + found = RestClient("tok", transport=transport).find_linked_pull_request("o/r", "python-schema/pr-7") + assert found is not None and not found.merged + + assert RestClient("tok", transport=Recorder({("GET", path): (200, [])})).find_linked_pull_request("o/r", "python-schema/pr-7") is None + +def test_rest_client_sets_a_commit_status_with_a_short_description(): + """Checks that set_commit_status posts to the commit status endpoint and cuts the description to 140 characters, the GitHub limit. + + Protects the status job from rejected API calls. + """ + transport = Recorder({("POST", "/repos/o/r/statuses/abc123"): (201, {})}) + RestClient("tok", transport=transport).set_commit_status("o/r", "abc123", "pending", CONTEXT, "x" * 300, "https://github.com/o/r/pull/3") + method, path, _, body = transport.requests[0] + assert (method, path) == ("POST", "/repos/o/r/statuses/abc123") + assert isinstance(body, dict) and body["state"] == "pending" and body["context"] == CONTEXT and len(str(body["description"])) == 140 diff --git a/.ci/unit-tests/python/test_py2cs/test_workflows.py b/.ci/unit-tests/python/test_py2cs/test_workflows.py new file mode 100644 index 0000000..d2bf0a2 --- /dev/null +++ b/.ci/unit-tests/python/test_py2cs/test_workflows.py @@ -0,0 +1,199 @@ +"""The GitHub workflows, read as data: what starts them, what they may write to, and where the tokens go.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +REPO = Path(__file__).resolve().parents[4] +WORKFLOWS = REPO / ".github" / "workflows" + +def load(name: str) -> dict[str, Any]: + """Reads one workflow file as YAML and returns it with the on key (read by YAML as True) put back under on. + + Every test here uses it to check workflows as data. + """ + data: dict[str, Any] = yaml.safe_load((WORKFLOWS / name).read_text(encoding="utf-8")) + # YAML reads the key "on" as True + data["on"] = data.pop(True) if True in data else data.get("on") + return data + +def lines(name: str) -> list[str]: + """Returns the stripped non-blank, non-comment lines of a text file at the repository root. + + The tests use it to read the repository lists. + """ + return [line.strip() for line in (REPO / name).read_text(encoding="utf-8").splitlines() if line.strip() and not line.strip().startswith("#")] + +def test_all_workflows_are_valid_and_have_the_expected_jobs(): + """Checks that the three workflows are valid YAML with the expected jobs. + + Protects the generation, PR checks and linked C# PR workflows from broken edits. + """ + assert list(load("python-schema-generation.yml")["jobs"]) == ["generate"] + assert list(load("pr-checks.yml")["jobs"]) == ["checks"] + assert list(load("linked-csharp-pr.yml")["jobs"]) == ["prepare", "repos", "raise", "close", "status", "sweep"] + +def test_the_weekly_generation_commit_cannot_start_the_linked_pr_workflow(): + """Checks that linked-csharp-pr.yml has no push trigger and runs for pull requests only on src/bhom_schema paths. + + Protects against the weekly generation commit, which is a push, starting a linked C# PR. + """ + triggers = load("linked-csharp-pr.yml")["on"] + assert set(triggers) == {"pull_request", "schedule", "workflow_dispatch"}, "never a push trigger: the weekly commit is a push" + assert triggers["pull_request"]["paths"] == ["src/bhom_schema/**"] + +def test_the_weekly_generation_commits_directly_and_is_not_a_pull_request(): + """Checks that the generation workflow runs on schedule or on demand and pushes straight to develop without opening a PR. + + Protects the weekly generation design. + """ + workflow = load("python-schema-generation.yml") + assert set(workflow["on"]) == {"schedule", "workflow_dispatch"} + text = (WORKFLOWS / "python-schema-generation.yml").read_text(encoding="utf-8") + assert "git push origin HEAD:develop" in text + assert "gh pr create" not in text and "schema-updates" not in text + +def test_workflows_start_with_read_only_permissions_and_raise_them_per_job(): + """Checks that the workflows default to read-only contents and that only the jobs that need more raise their permissions. + + Protects the token permissions of the workflows. + """ + assert load("linked-csharp-pr.yml")["permissions"] == {"contents": "read"} + assert load("pr-checks.yml")["permissions"] == {"contents": "read"} + assert load("linked-csharp-pr.yml")["jobs"]["raise"]["permissions"] == {"contents": "read", "pull-requests": "write"} + assert "permissions" not in load("linked-csharp-pr.yml")["jobs"]["close"] + +def test_the_app_token_is_made_for_one_repository_of_the_matrix_only(): + """Checks that the raise and close jobs make the GitHub App token for the single repository of the matrix and the owner. + + Protects the C# repositories from a token with wider reach. + """ + jobs = load("linked-csharp-pr.yml")["jobs"] + for name in ("raise", "close"): + step = next(s for s in jobs[name]["steps"] if str(s.get("uses", "")).startswith("actions/create-github-app-token")) + assert step["with"]["repositories"] == "${{ matrix.name }}" + assert step["with"]["owner"] == "${{ github.repository_owner }}" + +def test_tokens_only_reach_the_steps_that_need_them(): + """Checks that the app token reaches only steps that create it or need it, and not the planning step. + + Protects the token from the steps that run code from the PR. + """ + jobs = load("linked-csharp-pr.yml")["jobs"] + for job in jobs.values(): + for step in job["steps"]: + uses_token = "steps.token.outputs.token" in str(step) + assert not uses_token or step.get("id") == "token" or "PY2CS_CSHARP_TOKEN" in step.get("env", {}) or step.get("with", {}).get("token"), step.get("name") + + plan_step = next(s for s in jobs["raise"]["steps"] if s.get("name") == "Plan the C# changes") + assert "PY2CS_CSHARP_TOKEN" not in plan_step.get("env", {}), "planning does not need the token" + +def test_the_tooling_that_holds_the_token_comes_from_the_base_branch(): + """Checks that the linked-PR workflow runs py2cs from the base branch checkout, not from the PR head. + + Protects the token from code that a PR could change. + """ + text = (WORKFLOWS / "linked-csharp-pr.yml").read_text(encoding="utf-8") + assert "PYTHONPATH: base/.ci/generation" in text and "head/.ci/generation" not in text + +def test_fork_pull_requests_get_no_linked_pull_request(): + """Checks that the linked-PR workflow marks pull requests from forks as not eligible and that the repos job requires eligibility. + + Protects the secrets of the workflow from fork PRs. + """ + text = (WORKFLOWS / "linked-csharp-pr.yml").read_text(encoding="utf-8") + assert ".head.repo.full_name" in text and "eligible=false" in text + jobs = load("linked-csharp-pr.yml")["jobs"] + assert "eligible == 'true'" in jobs["repos"]["if"] + +def test_the_main_bhom_repository_is_never_written_to(): + """Checks that the list of converted repositories holds only the test repository and that BHoM is in the build list. + + Protects the main BHoM repository from C# PRs while the process is tested. + """ + converted = lines("Converted-repositories.txt") + assert converted == ["BHoM/CSharpSchema_Test"], "C# pull requests may only be raised in the test repository for now" + assert "BHoM/BHoM" in lines("Included-repositories.txt"), "BHoM is cloned and built (read only), but never written to" + +def test_every_listed_converted_repository_is_also_built(): + """Checks that every converted repository is also in the list of built repositories (compared without case). + + Protects the generator, which needs each converted repository to be built. + """ + built = {r.lower() for r in lines("Included-repositories.txt")} + assert all(r.lower() in built for r in lines("Converted-repositories.txt")) + +def test_the_generation_and_the_checks_build_the_same_repositories(): + """Checks that the generation and PR checks workflows clone and build the repositories from the same list. + + Protects align, which compares the PR with what the generator would write. + """ + for name in ("python-schema-generation.yml", "pr-checks.yml"): + text = (WORKFLOWS / name).read_text(encoding="utf-8") + assert "Clone-Repositories.ps1 -List Included-repositories.txt" in text and "Build-Repositories.ps1 -List Included-repositories.txt" in text + +def test_the_status_jobs_write_statuses_and_nothing_else(): + """Checks that the status and sweep jobs get statuses write permission, no other job does and the status jobs make no app token. + + Protects the permissions of the status part of the linked-PR workflow. + """ + jobs = load("linked-csharp-pr.yml")["jobs"] + assert jobs["status"]["permissions"] == {"contents": "read", "statuses": "write"} + assert jobs["sweep"]["permissions"] == {"contents": "read", "pull-requests": "read", "statuses": "write"} + for name in ("prepare", "repos", "raise", "close"): + assert "statuses" not in jobs[name].get("permissions", {}) + + # They read the C# repositories with the workflow's own token, so no GitHub App token is made for them + for name in ("status", "sweep"): + assert not any(str(s.get("uses", "")).startswith("actions/create-github-app-token") for s in jobs[name]["steps"]) + +def test_the_hourly_sweep_runs_only_on_the_schedule_and_the_other_jobs_never_do(): + """Checks that the sweep job runs only on the hourly schedule, the prepare job never does and the cron is 17 * * * *. + + Protects the hourly refresh of statuses from running the raise or close jobs. + """ + jobs = load("linked-csharp-pr.yml")["jobs"] + assert jobs["sweep"]["if"] == "github.event_name == 'schedule'" + assert jobs["prepare"]["if"] == "github.event_name != 'schedule'" + assert load("linked-csharp-pr.yml")["on"]["schedule"] == [{"cron": "17 * * * *"}] + +def test_a_python_pr_with_no_csharp_change_still_gets_a_status(): + """Checks that the status job also runs when no repository is affected and passes --no-csharp-change. + + Protects the required status from staying pending on a Python PR that needs no C# change. + """ + status = load("linked-csharp-pr.yml")["jobs"]["status"] + assert "needs.repos.outputs.any == 'false'" in status["if"] + assert "--no-csharp-change" in str(status["steps"]) + + +def test_the_release_workflow_is_manual_and_only_the_publish_job_gets_an_identity_token(): + """Checks that the release workflow starts only by hand, that contents are read-only by default, and that only the TestPyPI job can ask for an identity token. + + Protects the release from an unplanned trigger and TestPyPI from a token held by the build job. + """ + workflow = load("release.yml") + assert set(workflow["on"]) == {"workflow_dispatch"} + assert workflow["on"]["workflow_dispatch"]["inputs"]["dry_run"]["default"] is True + assert workflow["permissions"] == {"contents": "read"} + assert list(workflow["jobs"]) == ["build", "publish-testpypi"] + assert workflow["jobs"]["build"]["permissions"] == {"contents": "write"} + assert workflow["jobs"]["publish-testpypi"]["permissions"] == {"id-token": "write"} + assert workflow["jobs"]["publish-testpypi"]["environment"] == "testpypi" + + +def test_the_release_workflow_stores_no_secret_and_nothing_is_published_in_a_dry_run(): + """Checks that the release workflow reads no repository secret, passes --release to the generator, and keeps every push, release and publish step behind not dry run. + + Protects the release from a stored password and from publishing when only a check was asked for. + """ + workflow = load("release.yml") + text = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + assert "secrets." not in text + assert '--release "${{ inputs.version }}"' in text + assert workflow["jobs"]["publish-testpypi"]["if"] == "${{ !inputs.dry_run }}" + publishing = [s for s in workflow["jobs"]["build"]["steps"] if "git push" in s.get("run", "") or "gh release" in s.get("run", "")] + assert publishing and all(s["if"] == "${{ !inputs.dry_run }}" for s in publishing) diff --git a/.ci/unit-tests/python/test_spike/conftest.py b/.ci/unit-tests/python/test_spike/conftest.py new file mode 100644 index 0000000..4b71506 --- /dev/null +++ b/.ci/unit-tests/python/test_spike/conftest.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + +from support import attach, require_output + + +@pytest.fixture(scope="session", autouse=True) +def spike_package(): + """Session fixture, applied automatically to the spike suite: locates the generated 'spike' package (real BHoM types), attaches it so imports resolve to it, and returns its folder. + + Every test in `test_spike` relies on it to import the generator's output for the real oM assemblies. + """ + package = require_output("spike") + attach(package) + return package diff --git a/.ci/unit-tests/python/test_spike/test_generated_spike.py b/.ci/unit-tests/python/test_spike/test_generated_spike.py new file mode 100644 index 0000000..89fef3f --- /dev/null +++ b/.ci/unit-tests/python/test_spike/test_generated_spike.py @@ -0,0 +1,138 @@ +"""Behaviour of the Python generated from the real oM assemblies for the spike set.""" + +from __future__ import annotations + +import importlib +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from support import module_names, run_pyright + + +def cls(path: str) -> Any: + """Returns the generated class at a path such as `Geometry_oM.Line`, by importing `bhom_schema.` and taking the class named after the last segment. + + Helper used by the spike tests to get a generated type. + """ + module = importlib.import_module(f"bhom_schema.{path}") + return getattr(module, path.rsplit(".", 1)[-1]) + + +def test_every_module_imports_and_every_model_rebuilds(spike_package): + """Checks that every module generated from the real oM assemblies imports and that every Pydantic model in them rebuilds without error. + + Protects the generator's output on real BHoM types, not only on the small fixtures. + """ + names = module_names(spike_package) + assert len(names) > 30 + for name in names: + generated = getattr(importlib.import_module(name), name.rsplit(".", 1)[-1]) + if isinstance(generated, type) and issubclass(generated, BaseModel): + assert generated.model_rebuild(force=True) in (True, None), name + + +def test_pyright_strict_is_clean(spike_package): + """Checks that pyright in strict mode reports no problems on the Python generated from the real oM spike types. + + Protects the committed schema from type errors the fixtures alone would not reveal. + """ + result = run_pyright(spike_package.parent) + assert result.returncode == 0, result.stdout + result.stderr + + +# ---------------------------------------------------------------- the other spike types + +def test_line_and_curve_interfaces(): + """Checks that the generated `Line` has distinct start and end points, implements the curve, geometry and element interfaces, and records its assembly. + + Protects the generator's handling of a real interface hierarchy and object defaults. + """ + Line, Point = cls("Geometry_oM.Line"), cls("Geometry_oM.Point") + ICurve, IGeometry, IElement1D = cls("Geometry_oM.ICurve"), cls("Geometry_oM.IGeometry"), cls("Dimensional_oM.IElement1D") + line = Line() + assert isinstance(line.Start, Point) and line.Start is not line.End + assert issubclass(ICurve, IGeometry) and issubclass(ICurve, IElement1D) and isinstance(line, ICurve) + assert Line.__bhom__["assembly"] == "Geometry_oM" + + +def test_bhom_object_defaults(): + """Checks that two `BHoMObject` instances get different GUIDs and do not share their tags or custom data. + + Protects the generator's defaults on the base class of all oM types. + """ + BHoMObject = cls("BHoM.BHoMObject") + a, b = BHoMObject(), BHoMObject() + assert a.BHoM_Guid != b.BHoM_Guid + a.Tags.add("x") + a.CustomData["k"] = 1 + assert b.Tags == set() and b.CustomData == {} + + +def test_bar_is_a_bhom_object_with_a_quantity(): + """Checks that the generated structural `Bar` derives from `BHoMObject` and that its orientation angle carries the 'Angle' quantity. + + Protects base-class and quantity extraction on a real type. + """ + Bar, BHoMObject = cls("Structure_oM.Elements.Bar"), cls("BHoM.BHoMObject") + assert issubclass(Bar, BHoMObject) + angle = next(m for m in Bar.model_fields["OrientationAngle"].metadata if type(m).__name__ == "Quantity") + assert angle.category == "Angle" + + +def test_enum_with_none_member(): + """Checks that a real enum with a `None` member is generated with `None_` and records the rename in `__bhom__`. + + Protects the enum naming rule on a real assembly. + """ + options = cls("Graphics_oM.Enums.GradientCenteringOptions") + assert [m.name for m in options] == ["None_", "Asymmetric", "Symmetric"] + assert options.__bhom__["renamed_members"] == {"None_": "None"} + + +def test_generic_with_constraint(): + """Checks that the generated `BHoMGroup` records its CLR name, accepts a list of the bound type and rejects invalid items. + + Protects the generic and constraint mapping on a real type. + """ + BHoMGroup_1, BHoMObject = cls("BHoM.BHoMGroup_1"), cls("BHoM.BHoMObject") + assert BHoMGroup_1.__bhom__["clr_name"] == "BH.oM.Base.BHoMGroup`1" + group = BHoMGroup_1[BHoMObject](Elements=[BHoMObject()]) + assert len(group.Elements) == 1 + with pytest.raises(ValidationError): + BHoMGroup_1[BHoMObject](Elements=[1]) + + +def test_same_named_rooms_are_different_classes(): + """Checks that the two `Room` classes in the acoustic and architecture assemblies are separate classes with their own namespaces. + + Protects name tracking when real assemblies reuse a type name. + """ + acoustic, architecture = cls("Acoustic_oM.Room"), cls("Architecture_oM.Elements.Room") + assert acoustic is not architecture + assert acoustic.__bhom__["namespace"] == "BH.oM.Acoustic" + assert architecture.__bhom__["namespace"] == "BH.oM.Architecture.Elements" + + +def test_gradient_has_color_and_decimal_keyed_markers(): + """Checks that the generated `Gradient` has a `Markers` field that defaults to an empty dictionary. + + Protects the mapping of a dictionary keyed by decimal numbers with colour values. + """ + Gradient = cls("Graphics_oM.Gradient") + assert "Markers" in Gradient.model_fields + assert Gradient().Markers == {} + + +def test_comparison_config_holds_object_collections_as_lists(): + """Checks that `ComparisonConfig` holds its tolerance objects in a list that starts empty and accepts new items. + + Protects the type mapper's list fallback for collections of objects on a real type. + """ + ComparisonConfig = cls("BHoM.ComparisonConfig") + NamedNumericTolerance = cls("BHoM.NamedNumericTolerance") + config = ComparisonConfig() + assert config.PropertyNumericTolerances == [] + config.PropertyNumericTolerances.append(NamedNumericTolerance()) + assert len(config.PropertyNumericTolerances) == 1 diff --git a/.ci/unit-tests/python/test_spike/test_json.py b/.ci/unit-tests/python/test_spike/test_json.py new file mode 100644 index 0000000..b81dbd0 --- /dev/null +++ b/.ci/unit-tests/python/test_spike/test_json.py @@ -0,0 +1,121 @@ +"""The JSON of the Python generated from real BHoM types: it round trips, every class has a JSON Schema, and what is written is what BHoM_JSONSchema accepts. + +The last group needs a clone of BHoM_JSONSchema (next to this repository, or at BHOM_JSONSCHEMA_ROOT). The clone is read only and is only +the test: the generator does not use that repository. +""" + +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator +from pydantic import BaseModel +from referencing import Registry, Resource +from referencing.exceptions import NoSuchResource +from referencing.jsonschema import DRAFT202012 + +from support import module_names + +RAW = "https://raw.githubusercontent.com/BHoM/BHoM_JSONSchema/develop/" +ROOT = Path(os.environ.get("BHOM_JSONSCHEMA_ROOT", Path(__file__).resolve().parents[5] / "BHoM_JSONSchema")) + +def cls(path: str) -> Any: + """Returns the generated class at a path such as `Geometry_oM.Line`, by importing `bhom_schema.` and taking the class named after the last segment.""" + module = importlib.import_module(f"bhom_schema.{path}") + return getattr(module, path.rsplit(".", 1)[-1]) + +def retrieve(uri: str) -> Resource[Any]: + """Finds a schema that another schema refers to, in the local clone instead of on GitHub: the raw URL of a file maps to the same path in the clone.""" + if not uri.startswith(RAW) or not (ROOT / uri[len(RAW):]).is_file(): + raise NoSuchResource(uri) + + return Resource.from_contents(json.loads((ROOT / uri[len(RAW):]).read_text(encoding="utf-8")), default_specification=DRAFT202012) + +def validator(schema_path: str) -> Draft202012Validator: + """Builds a validator for one published schema, such as `Geometry_oM/Line.json`, that resolves its references in the local clone.""" + schema: dict[str, Any] = json.loads((ROOT / schema_path).read_text(encoding="utf-8")) + return Draft202012Validator(schema, registry=Registry(retrieve=retrieve)) # type: ignore[arg-type] + +def errors(schema_path: str, instance: Any) -> list[str]: + """Lists, as text, why an instance does not validate against a published schema; empty when it does.""" + return sorted(f"{'/'.join(str(p) for p in e.absolute_path)}: {e.message[:140]}" for e in validator(schema_path).iter_errors(instance)) + +def written(model: BaseModel) -> Any: + """Saves a model to JSON text and reads it as plain data, the way another program would receive it.""" + return json.loads(model.model_dump_json()) + +needs_clone = pytest.mark.skipif(not ROOT.is_dir(), reason=f"no clone of BHoM_JSONSchema at {ROOT}; set BHOM_JSONSCHEMA_ROOT") + +def bar() -> Any: + """Builds a small Bar from generated classes: two nodes, so JSON has nested objects, an enum, a quantity and the BHoMObject fields.""" + Bar, Node, Point = cls("Structure_oM.Elements.Bar"), cls("Structure_oM.Elements.Node"), cls("Geometry_oM.Point") + return Bar(Name="B1", Start=Node(Position=Point(X=0, Y=0, Z=0)), End=Node(Position=Point(X=3, Y=0, Z=0)), OrientationAngle=0.5, + FEAType=cls("Structure_oM.Elements.BarFEAType").Axial, Tags={"a", "b"}, CustomData={"k": 1}) + +def test_every_generated_class_has_a_json_schema(spike_package: Path): + """Checks that `model_json_schema()` works for every generated class; before the interface support it failed for every BHoMObject.""" + for name in module_names(spike_package): + generated = getattr(importlib.import_module(name), name.rsplit(".", 1)[-1]) + if isinstance(generated, type) and issubclass(generated, BaseModel): + assert generated.model_json_schema(), name + +def test_a_real_class_round_trips_through_json(): + """Checks that a Bar, with nested nodes, an enum, a set and a dictionary, is the same object after saving to JSON and loading again.""" + original = bar() + again = type(original).model_validate_json(original.model_dump_json()) + assert again == original + data = written(original) + assert data["_t"] == "BH.oM.Structure.Elements.Bar" and data["FEAType"] == "Axial" and data["Start"]["_t"] == "BH.oM.Structure.Elements.Node" + +def test_a_curve_saved_as_an_interface_value_loads_as_its_class(): + """Checks the interface rule on real types: JSON saved from a Line names its class in `_t`, and the ICurve interface field of Edge loads a Line.""" + Line, Point = cls("Geometry_oM.Line"), cls("Geometry_oM.Point") + line = Line(Start=Point(X=1), End=Point(X=2)) + assert "BH.oM.Geometry.Line" in cls("Geometry_oM.ICurve").__bhom__["implementers"] + assert type(Line.model_validate_json(line.model_dump_json())) is Line + +@needs_clone +def test_a_point_and_a_line_are_valid_against_the_published_schemas(): + """Checks that JSON saved from generated Python validates against the schemas BHoM_JSONSchema publishes for Point and Line.""" + Line, Point = cls("Geometry_oM.Line"), cls("Geometry_oM.Point") + line = Line(Start=Point(X=1, Y=2, Z=3), End=Point(X=4, Y=5, Z=6)) + assert errors("Geometry_oM/Point.json", written(Point(X=1, Y=2, Z=3))) == [] + assert errors("Geometry_oM/Line.json", written(line)) == [] + +@needs_clone +def test_a_line_is_valid_as_an_icurve_because_it_carries_its_type_tag(): + """Checks the interface rule of the published ICurve schema: it requires `_t`, picks the class from it, and then validates the Line.""" + Line, Point = cls("Geometry_oM.Line"), cls("Geometry_oM.Point") + data = written(Line(Start=Point(X=1), End=Point(X=2))) + assert errors("Geometry_oM/ICurve.json", data) == [] + + without_tag = {key: value for key, value in data.items() if key != "_t"} + assert any("'_t' is a required property" in e for e in errors("Geometry_oM/ICurve.json", without_tag)) + +@needs_clone +def test_a_bar_is_valid_against_the_published_schema(): + """Checks a whole real class: the Bar saved from Python, with nodes, an enum written as its name, a set, a dictionary and the BHoMObject fields, is valid.""" + assert errors("Structure_oM/Elements/Bar.json", written(bar())) == [] + +@needs_clone +def test_an_enum_is_valid_as_its_name_and_as_the_object_form(): + """Checks that the name Python writes for an enum, and the object form BHoM writes for a top-level enum, are both accepted by the published enum schema.""" + Options = cls("Graphics_oM.Enums.GradientCenteringOptions") + path = "Graphics_oM/Enums/GradientCenteringOptions.json" + assert errors(path, Options.None_.bhom_name()) == [] and Options.None_.bhom_name() == "None" + form = {"_t": "System.Enum", "TypeName": {"_t": "System.Type", "Name": "BH.oM.Graphics.Enums.GradientCenteringOptions"}, "Value": "Symmetric"} + assert errors(path, form) == [] + + holder = Options.from_bhom(form) + assert holder is Options.Symmetric + +@needs_clone +def test_the_published_schemas_reject_what_python_would_not_write(): + """Checks that the comparison above can fail: a Point with a field missing, or with the wrong type of value, is invalid in the published schema.""" + assert any("'Z' is a required property" in e for e in errors("Geometry_oM/Point.json", {"X": 1, "Y": 2})) + assert errors("Geometry_oM/Point.json", {"X": "one", "Y": 2, "Z": 3}) != [] diff --git a/.ci/unit-tests/python/test_support/test_json.py b/.ci/unit-tests/python/test_support/test_json.py new file mode 100644 index 0000000..94b3175 --- /dev/null +++ b/.ci/unit-tests/python/test_support/test_json.py @@ -0,0 +1,220 @@ +"""The JSON of the hand-written support package: `_t`, `_bhomVersion`, interface fields and enums, as BHoM_JSONSchema describes them. + +The classes here are small stand-ins for generated ones, so these tests need no generated output. +""" + +from __future__ import annotations + +import json +import sys +import types +from abc import ABC +from typing import Any, ClassVar + +import pytest +from pydantic import ValidationError + +from bhom_schema._support import BHoMEnum, BHoMFlags, Polymorphic, SchemaModel + +class ICurve(ABC): + """A stand-in interface with two implementers.""" + __bhom__: ClassVar[dict[str, Any]] = { + "clr_name": "BH.oM.Geometry.ICurve", "kind": "interface", + "implementers": {"BH.oM.Geometry.Line": "TestAsm.Line", "BH.oM.Geometry.Arc": "TestAsm.Arc"}, + } + +class INothing(ABC): + """A stand-in interface that nothing implements.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Geometry.INothing", "kind": "interface"} + +class Point(SchemaModel): + """A stand-in class.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Geometry.Point", "kind": "class"} + X: float = 0.0 + +class Line(SchemaModel, ICurve): + """A stand-in implementer with required fields.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Geometry.Line", "kind": "class"} + Start: Point + End: Point + +class Arc(SchemaModel, ICurve): + """A stand-in implementer with defaults only.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Geometry.Arc", "kind": "class"} + Radius: float = 1.0 + +class Edge(SchemaModel): + """A stand-in class with interface fields of every shape.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Structure.Edge", "kind": "class"} + Curve: Polymorphic[ICurve] + Curves: list[Polymorphic[ICurve]] = [] + Maybe: Polymorphic[ICurve] | None = None + Never: Polymorphic[INothing] | None = None + +class Mode(BHoMEnum): + """A stand-in enum with a member that C# calls None.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Mode", "kind": "enum", "renamed_members": {"None_": "None"}} + None_ = 0 + Fast = 1 + +class Options(BHoMFlags): + """A stand-in flags enum with a named combination.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Options", "kind": "enum", "flags": True} + A = 1 + B = 2 + Both = 3 + +class Holder(SchemaModel): + """A stand-in class with enum fields.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Holder", "kind": "class"} + Level: Mode = Mode.Fast + Flags: Options = Options.A + +class Sets(SchemaModel): + """A stand-in class with a list that BHoM may write as a FragmentSet.""" + __bhom__: ClassVar[dict[str, Any]] = {"clr_name": "BH.oM.Sets", "kind": "class"} + Fragments: list[Point] = [] + +LINE = {"_t": "BH.oM.Geometry.Line", "Start": {"X": 1}, "End": {"X": 2}} + +@pytest.fixture(autouse=True) +def implementer_modules(monkeypatch: pytest.MonkeyPatch) -> None: + """Makes `bhom_schema.TestAsm.Line` and `.Arc` importable, as the generated modules of the implementers would be.""" + for cls in (Line, Arc): + module = types.ModuleType(f"bhom_schema.TestAsm.{cls.__name__}") + setattr(module, cls.__name__, cls) + monkeypatch.setitem(sys.modules, module.__name__, module) + +# ---------------------------------------------------------------- _t and _bhomVersion + +def test_every_object_is_written_with_its_class_name_first(): + """Checks that saving a model writes `_t` as the first entry, for the model and for the models inside it.""" + data = json.loads(Edge(Curve=Line(Start=Point(X=1), End=Point(X=2))).model_dump_json()) + assert list(data)[0] == "_t" and data["_t"] == "BH.oM.Structure.Edge" + assert data["Curve"]["_t"] == "BH.oM.Geometry.Line" and data["Curve"]["Start"]["_t"] == "BH.oM.Geometry.Point" + +def test_a_type_tag_is_optional_but_must_be_right_when_it_is_there(): + """Checks that `_t` may be left out, is accepted when it names the class, and is refused when it names another class.""" + assert Point.model_validate({"X": 1}).X == 1 + assert Point.model_validate({"_t": "BH.oM.Geometry.Point", "X": 1}).X == 1 + with pytest.raises(ValidationError, match="_t is BH.oM.Geometry.Arc, but this class is BH.oM.Geometry.Point"): + Point.model_validate({"_t": "BH.oM.Geometry.Arc"}) + +def test_bhom_version_is_left_out_until_it_is_set_and_read_back(): + """Checks that `_bhomVersion` is not written when empty, and is written, and read, under that name when set.""" + assert "_bhomVersion" not in json.loads(Point().model_dump_json()) + versioned = Point(bhom_version="10.0") + assert json.loads(versioned.model_dump_json())["_bhomVersion"] == "10.0" + assert Point.model_validate_json(versioned.model_dump_json()).bhom_version == "10.0" + +def test_a_fragment_set_written_as_an_object_is_read_as_a_list(): + """Checks that BHoM's `{"_t": "BH.oM.Base.FragmentSet", "_v": [...]}` form and a plain array both fill a list field.""" + wrapped = {"Fragments": {"_t": "BH.oM.Base.FragmentSet", "_v": [{"X": 1}, {"X": 2}]}} + assert [p.X for p in Sets.model_validate(wrapped).Fragments] == [1, 2] + assert [p.X for p in Sets.model_validate({"Fragments": [{"X": 3}]}).Fragments] == [3] + assert Sets.model_validate({"Fragments": {"_t": "BH.oM.Base.FragmentSet"}}).Fragments == [] + +def test_the_json_schema_of_a_class_has_the_optional_type_property(): + """Checks that `model_json_schema()` lists `_t` (fixed to the class name) and `_bhomVersion`, as the BHoM_JSONSchema files do.""" + properties = Point.model_json_schema()["properties"] + assert properties["_t"]["const"] == "BH.oM.Geometry.Point" and "_bhomVersion" in properties + +# ---------------------------------------------------------------- interface fields + +def test_an_interface_field_loads_the_class_named_by_its_type_tag(): + """Checks the loop that failed before: an Edge saved to JSON loads again, and its curve is a Line, not just any curve.""" + edge = Edge(Curve=Line(Start=Point(X=1), End=Point(X=2)), Curves=[Arc(Radius=3)]) + again = Edge.model_validate_json(edge.model_dump_json()) + assert type(again.Curve) is Line and again.Curve.End.X == 2 + assert type(again.Curves[0]) is Arc and again.Curves[0].Radius == 3 + assert again == edge + +def test_the_class_is_imported_only_when_a_type_tag_names_it(monkeypatch: pytest.MonkeyPatch): + """Checks that an implementer's module is not imported until JSON names it, so loading one class does not load the whole package.""" + imported: list[str] = [] + real = __import__("importlib").import_module + monkeypatch.setattr("bhom_schema._support.polymorphic.importlib.import_module", lambda name: (imported.append(name), real(name))[1]) + Edge.model_validate({"Curve": {"_t": "BH.oM.Geometry.Arc"}}) + assert imported == ["bhom_schema.TestAsm.Arc"] + +def test_objects_of_the_interface_are_accepted_as_they_are_and_others_are_not(): + """Checks that an object of an implementing class needs no `_t`, and an object of an unrelated class is refused.""" + arc = Arc() + assert Edge(Curve=arc).Curve is arc + with pytest.raises(ValidationError, match="expected an object of ICurve"): + Edge(Curve=Point()) # type: ignore[arg-type] + with pytest.raises(ValidationError, match="expected an object of ICurve"): + Edge.model_validate({"Curve": "Line"}) + +def test_a_value_validated_as_an_interface_must_carry_a_known_type_tag(): + """Checks the two refusals: no `_t` at all, and a `_t` that is not one of the interface's implementers.""" + with pytest.raises(ValidationError, match="needs _t, the name of its class"): + Edge.model_validate({"Curve": {"Radius": 1}}) + with pytest.raises(ValidationError, match="BH.oM.Other.Foo is not a class of ICurve in bhom_schema"): + Edge.model_validate({"Curve": {"_t": "BH.oM.Other.Foo"}}) + +def test_the_error_names_the_real_problem_of_the_chosen_class_only(): + """Checks that a Line with a missing end reports that field and nothing about Arc, the other implementer.""" + with pytest.raises(ValidationError) as error: + Edge.model_validate({"Curve": {"_t": "BH.oM.Geometry.Line", "Start": {"X": 1}}}) + text = str(error.value) + assert "End" in text and "Field required" in text and "Radius" not in text and "Arc" not in text + +def test_optional_and_list_interface_fields_work_too(): + """Checks `Polymorphic[...] | None` and `list[Polymorphic[...]]`: empty, null and filled.""" + assert Edge.model_validate({"Curve": {"_t": "BH.oM.Geometry.Arc"}, "Maybe": None}).Maybe is None + again = Edge.model_validate({"Curve": {"_t": "BH.oM.Geometry.Arc"}, "Curves": [LINE, {"_t": "BH.oM.Geometry.Arc"}]}) + assert [type(c) for c in again.Curves] == [Line, Arc] + +def test_an_interface_with_no_implementers_accepts_objects_only(): + """Checks that an interface the generator found no implementer for refuses JSON, with a clear message, instead of failing in some other way.""" + with pytest.raises(ValidationError, match="is not a class of INothing"): + Edge.model_validate({"Curve": {"_t": "BH.oM.Geometry.Arc"}, "Never": {"_t": "BH.oM.Geometry.Arc"}}) + +def test_the_json_schema_of_an_interface_field_is_the_one_of_bhom_json_schema(): + """Checks that the field's schema requires `_t`, lists the implementers as its allowed values, and picks each class with an if/then rule.""" + schema = Edge.model_json_schema() + interface = schema["$defs"]["Polymorphic_ICurve_"] + assert interface["required"] == ["_t"] + assert interface["properties"]["_t"]["enum"] == ["BH.oM.Geometry.Line", "BH.oM.Geometry.Arc"] + rule = interface["allOf"][0] + assert rule["if"]["properties"]["_t"]["const"] == "BH.oM.Geometry.Line" and rule["if"]["required"] == ["_t"] + assert rule["then"] == {"$ref": "#/$defs/Line"} + assert schema["properties"]["Curve"] == {"$ref": "#/$defs/Polymorphic_ICurve_"} + +# ---------------------------------------------------------------- enums + +def test_an_enum_is_written_as_its_name_and_none_keeps_its_csharp_spelling(): + """Checks that members are saved by name, and that `None_` is saved as "None" from the renamed members of the marker.""" + assert json.loads(Holder().model_dump_json())["Level"] == "Fast" + assert json.loads(Holder(Level=Mode.None_).model_dump_json())["Level"] == "None" + +def test_an_enum_is_read_from_a_name_a_number_or_the_object_form(): + """Checks every form BHoM JSON, or a Python caller, may hold an enum in.""" + assert Holder.model_validate({"Level": "None"}).Level is Mode.None_ + assert Holder.model_validate({"Level": 1}).Level is Mode.Fast + form = {"_t": "System.Enum", "TypeName": {"_t": "System.Type", "Name": "BH.oM.Mode"}, "Value": "Fast"} + assert Holder.model_validate({"Level": form}).Level is Mode.Fast + assert Holder(Level=Mode.None_).Level is Mode.None_ + +def test_an_enum_refuses_what_is_not_a_member(): + """Checks the messages for an unknown name, an unknown number, a boolean and several names for a plain enum.""" + for bad, message in (("Slow", "Slow is not a member of Mode"), (7, "7 is not a value of Mode"), (True, "True is not a value of Mode"), + ("Fast, None", "names several members")): + with pytest.raises(ValidationError, match=message): + Holder.model_validate({"Level": bad}) + +def test_flags_are_written_and_read_as_comma_separated_names(): + """Checks a defined combination (by its own name), an undefined combination (the single flags joined), and reading both back.""" + assert json.loads(Holder(Flags=Options.A).model_dump_json())["Flags"] == "A" + assert json.loads(Holder(Flags=Options.A | Options.B).model_dump_json())["Flags"] == "Both" + assert Holder.model_validate({"Flags": "A, B"}).Flags == Options.Both + assert Holder.model_validate({"Flags": "B"}).Flags is Options.B + +def test_the_json_schema_of_an_enum_is_a_name_or_the_object_form(): + """Checks the `anyOf` of BHoM_JSONSchema: the allowed names, or the System.Enum object that holds the same name.""" + holder = Holder.model_json_schema()["properties"] + names, form = holder["Level"]["anyOf"] + assert names["enum"] == ["None", "Fast"] + assert form["properties"]["_t"]["const"] == "System.Enum" and form["properties"]["Value"]["enum"] == ["None", "Fast"] + assert "enum" not in holder["Flags"]["anyOf"][0], "combined flags are free text" diff --git a/.ci/unit-tests/run-all.ps1 b/.ci/unit-tests/run-all.ps1 new file mode 100644 index 0000000..8d2a1a4 --- /dev/null +++ b/.ci/unit-tests/run-all.ps1 @@ -0,0 +1,69 @@ +# Runs every stage end to end: build, C# tests (which also generate the Python), the py2cs drafts, the Roslyn round trip, +# the pytest suites and Pyright. Stops at the first failing stage. +# +# .\run-all.ps1 full run +# .\run-all.ps1 -SkipPyright skip the Pyright pass over the repository sources +# +# It never builds a BHoM repository for you: missing assemblies are reported by the preflight, and the real-type tests +# are ignored locally (they fail when the CI environment variable is "true"). + +param([switch]$SkipPyright) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$solution = Join-Path $root ".ci\generation\PythonSchemaGeneration.sln" +$output = Join-Path $PSScriptRoot "_output" +$python = Join-Path $root ".venv\Scripts\python.exe" +Set-Location $root + +function Step($name, [scriptblock]$body) { + Write-Host "`n=== $name" -ForegroundColor Cyan + & $body + if ($LASTEXITCODE -ne 0) { throw "Stage failed: $name (exit code $LASTEXITCODE)" } +} + +# ---- preflight +Write-Host "=== Preflight" -ForegroundColor Cyan +$assemblies = Join-Path $env:ProgramData "BHoM\Assemblies" +$required = "BHoM", "BHoM_Engine", "Reflection_Engine", "JsonSchema_oM", "JsonSchema_Engine", "Quantities_oM", + "Geometry_oM", "Structure_oM", "Graphics_oM", "Acoustic_oM", "Architecture_oM" +$missing = $required | Where-Object { -not (Test-Path (Join-Path $assemblies "$_.dll")) } +if ($missing) { + Write-Host "Missing from ${assemblies}: $($missing -join ', ')" -ForegroundColor Yellow + Write-Host "Real-type tests will be ignored (CI=true makes them fail). Build the owning BHoM repos to install the dlls." -ForegroundColor Yellow +} else { + Write-Host "All $($required.Count) required assemblies are installed." +} +$testClasses = if ($env:CSharpSchemaTestRoot) { $env:CSharpSchemaTestRoot } else { Join-Path (Split-Path $root -Parent) "CSharpSchema_Test" } +if (-not (Test-Path (Join-Path $testClasses "FixtureAlpha_oM\FixtureAlpha_oM.csproj"))) { + throw "The test classes were not found at $testClasses. Clone https://github.com/BHoM/CSharpSchema_Test next to this repository, or set the CSharpSchemaTestRoot environment variable." +} +if (-not (Test-Path $python)) { throw "No virtual environment: run python -m venv .venv ; .venv\Scripts\python -m pip install -e '.[dev]'" } +$env:PYTHONPATH = Join-Path $root ".ci\generation" + +# ---- C# +Step "dotnet build" { dotnet build $solution -c Release --nologo -v quiet } +Step "dotnet test (generates the fixture and spike Python)" { dotnet test $solution -c Release --no-build --nologo --filter "Category!=RoundTrip" } + +# ---- Python to C# drafts and the round trip +foreach ($set in "fixtures", "spike") { + $package = Join-Path $output "$set\bhom_schema" + if (Test-Path $package) { + Step "py2cs drafts ($set)" { & $python -m py2cs drafts --root $package --out (Join-Path $output "roundtrip\$set") } + } +} +Step "dotnet test (Roslyn round trip)" { dotnet test $solution -c Release --no-build --nologo --filter "Category=RoundTrip" } + +# ---- Python (separate sessions: the fixture and spike packages share assembly folder names such as BHoM) +Step "pytest py2cs" { & $python -m pytest ".ci/unit-tests/python/test_py2cs" -q } +Step "pytest support package (JSON)" { & $python -m pytest ".ci/unit-tests/python/test_support" -q } +Step "pytest generated fixtures" { & $python -m pytest ".ci/unit-tests/python/test_fixtures" -q } +Step "pytest generated spike types" { & $python -m pytest ".ci/unit-tests/python/test_spike" -q } +Step "pytest alignment and round trip" { & $python -m pytest ".ci/unit-tests/python/test_alignment" -q } + +# ---- Pyright over the repository sources (the generated output is checked inside the pytest suites) +if (-not $SkipPyright) { + Step "pyright" { & $python -m pyright --pythonpath $python } +} + +Write-Host "`nAll stages passed." -ForegroundColor Green diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e87d124 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Generated files are written with LF line endings and must stay that way +src/bhom_schema/**/*.py text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..41abcee --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Global code owners for all files in this repository +# These users will be automatically requested for review on all PRs + +* @vietle-bh + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3f70af9..7012422 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,16 @@ Closes # +### Python schema changes (only for PRs that change `src/bhom_schema`) + + +- [ ] Linked C# PR: +- [ ] The C# PR is merged, or this PR is closed with a comment if the C# PR was closed +- [ ] `py2cs check` reports no errors +- [ ] `py2cs align` passes against the merged C# (first value is the C#, second this PR) +- [ ] Breaking changes (removed or renamed property, changed type, removed enum member or class) have a Versioning_Toolkit upgrade, or none is needed + + ### Test files diff --git a/.github/scripts/Build-Repositories.ps1 b/.github/scripts/Build-Repositories.ps1 new file mode 100644 index 0000000..29643f2 --- /dev/null +++ b/.github/scripts/Build-Repositories.ps1 @@ -0,0 +1,64 @@ +# Builds the cloned repositories in dependency order, with the same build.ps1 as the BHoM_JSONSchema weekly job. +# +# Build-Repositories.ps1 -List Included-repositories.txt [-ReposRoot .repos] [-OrderOnly] +# +# The order comes from the dependencies.txt of each repository (lines like BHoM/BHoM), among the listed repositories only. Building a +# BHoM repository copies its assemblies to %ProgramData%\BHoM\Assemblies, where the next repository and the generator find them. +# With -OrderOnly the order is printed and nothing is built. +param( + [Parameter(Mandatory = $true)][string]$List, + [string]$ReposRoot = ".repos", + [switch]$OrderOnly +) + +$ErrorActionPreference = "Stop" + +$names = Get-Content $List | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" -and -not $_.StartsWith("#") } | + ForEach-Object { $_.Split("/")[-1] } | Select-Object -Unique + +$dependencies = @{} +foreach ($name in $names) { + $dependencies[$name] = @() + $file = Join-Path $ReposRoot "$name/dependencies.txt" + if (Test-Path $file) { + $dependencies[$name] = @(Get-Content $file | ForEach-Object { $_.Trim() } | + Where-Object { $_ -ne "" -and -not $_.StartsWith("#") } | + ForEach-Object { $_.Split("/")[-1] } | + Where-Object { $names -contains $_ -and $_ -ne $name }) + } +} + +$script:order = @() +$script:state = @{} + +function Visit([string]$name) { + if ($script:state[$name] -eq "done") { return } + if ($script:state[$name] -eq "visiting") { throw "Circular dependency involving $name" } + $script:state[$name] = "visiting" + foreach ($dependency in $dependencies[$name]) { Visit $dependency } + $script:state[$name] = "done" + $script:order += $name +} + +foreach ($name in $names) { Visit $name } + +Write-Host "Build order: $($script:order -join ', ')" +if ($OrderOnly) { return } + +$buildScript = Join-Path $PSScriptRoot "../workflows/build.ps1" +foreach ($name in $script:order) { + Push-Location (Join-Path $ReposRoot $name) + try { + Write-Host "Building $name..." + $solution = Get-ChildItem -Filter *.sln | Select-Object -First 1 + if ($solution) { + & $buildScript -SolutionOrProjectPath $solution.FullName + } else { + foreach ($project in Get-ChildItem -Recurse -Filter *.csproj) { + & $buildScript -SolutionOrProjectPath $project.FullName + } + } + } finally { + Pop-Location + } +} diff --git a/.github/scripts/Clone-Repositories.ps1 b/.github/scripts/Clone-Repositories.ps1 new file mode 100644 index 0000000..cb1ad8a --- /dev/null +++ b/.github/scripts/Clone-Repositories.ps1 @@ -0,0 +1,42 @@ +# Clones the repositories to build (read only: nothing is ever pushed to them) into one folder, and records the commit of each. +# +# Clone-Repositories.ps1 -List Included-repositories.txt [-Target .repos] +# +# Each repository comes from its develop branch, or main, or master. /commits.txt gets one "Owner/Name " line per repository, +# for the commit message of the generation, so anyone can reproduce it. +param( + [Parameter(Mandatory = $true)][string]$List, + [string]$Target = ".repos" +) + +$ErrorActionPreference = "Stop" + +$repositories = Get-Content $List | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" -and -not $_.StartsWith("#") } | Select-Object -Unique + +New-Item -ItemType Directory -Force $Target | Out-Null +$commits = @() + +foreach ($repository in $repositories) { + $name = $repository.Split("/")[1] + $folder = Join-Path $Target $name + $cloned = $false + + foreach ($branch in "develop", "main", "master") { + Write-Host "Cloning $repository ($branch)..." + git clone "https://github.com/$repository.git" $folder --branch $branch --depth 1 --quiet 2>$null + if ($LASTEXITCODE -eq 0) { + $cloned = $true + break + } + } + + if (-not $cloned) { + throw "Could not clone $repository (tried develop, main and master)" + } + + $commit = (git -C $folder rev-parse HEAD).Trim() + Write-Host " $repository at $commit" + $commits += "$repository $commit" +} + +$commits | Set-Content (Join-Path $Target "commits.txt") diff --git a/.github/workflows/build.ps1 b/.github/workflows/build.ps1 new file mode 100644 index 0000000..ef1cd32 --- /dev/null +++ b/.github/workflows/build.ps1 @@ -0,0 +1,78 @@ +# build.ps1 +param( + [string]$SolutionOrProjectPath +) + +function Test-IsSdkProject { + param([string]$csprojPath) + try { + [xml]$xml = Get-Content -LiteralPath $csprojPath + } catch { return $false } + # SDK-style has on the root element + return $xml.Project -and $xml.Project.Sdk +} + +function Get-SolutionProjects { + param([string]$slnPath) + $root = Split-Path -Parent $slnPath + Select-String -Path $slnPath -Pattern 'Project\(".*"\)\s*=\s*".*?",\s*"(.*?)",\s*".*?"' | + ForEach-Object { + $rel = $_.Matches[0].Groups[1].Value + if ($rel -and $rel.EndsWith(".csproj", [StringComparison]::OrdinalIgnoreCase)) { + Join-Path $root $rel + } + } | Where-Object { Test-Path $_ } +} + +function Classify-Build { + param([string]$path) + + if ($path.EndsWith(".sln")) { + $projects = Get-SolutionProjects $path + if (-not $projects) { return "none" } + + $hasPackagesConfig = $projects | ForEach-Object { + $pkg = Join-Path (Split-Path -Parent $_) "packages.config" + Test-Path $pkg + } | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count + + if ($hasPackagesConfig -gt 0) { return "legacy" } + + $allSdk = $projects | ForEach-Object { Test-IsSdkProject $_ } | + Where-Object { -not $_ } | Measure-Object | Select-Object -ExpandProperty Count + return ($allSdk -eq 0) ? "sdk" : "legacy" + } + elseif ($path.EndsWith(".csproj")) { + $pkg = Join-Path (Split-Path -Parent $path) "packages.config" + if (Test-Path $pkg) { return "legacy" } + return (Test-IsSdkProject $path) ? "sdk" : "legacy" + } + else { + return "none" + } +} + +$path = Resolve-Path $SolutionOrProjectPath +$kind = Classify-Build $path +Write-Host "Detected build kind: $kind" + +switch ($kind) { + "sdk" { + Write-Host "Using dotnet restore/build" + dotnet restore "$path" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build "$path" --configuration Release --no-restore --verbosity minimal + exit $LASTEXITCODE + } + "legacy" { + Write-Host "Using NuGet + MSBuild (/restore)" + nuget restore "$path" -NonInteractive + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + msbuild "$path" /restore /m /p:Configuration=Release /p:Platform="Any CPU" /verbosity:minimal /nologo + exit $LASTEXITCODE + } + default { + Write-Host "No solution or project found or unrecognized format." + exit 0 + } +} diff --git a/.github/workflows/linked-csharp-pr.yml b/.github/workflows/linked-csharp-pr.yml new file mode 100644 index 0000000..b83dfdc --- /dev/null +++ b/.github/workflows/linked-csharp-pr.yml @@ -0,0 +1,302 @@ +name: Linked C# pull request + +# A pull request that changes the Python classes raises a linked DRAFT pull request in the repository that owns the C# classes. +# It never raises anything for a push, so the weekly generation (a push to develop) cannot start it. +# +# It also sets a commit status on the Python PR, "linked C# pull request": pending while the C# draft is open, success once it is +# merged, failure if it was closed without merging. Nothing tells this repository when a C# PR is merged, so an hourly run looks at +# every open Python PR and updates the status. Make the status a required check on develop to stop a Python PR being merged first. +# +# Where C# pull requests may be raised is listed in Converted-repositories.txt. The GitHub App token is made for one repository at a +# time, and only that repository is written to. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] + paths: + - 'src/bhom_schema/**' + schedule: + - cron: '17 * * * *' # hourly: refresh the status of open Python PRs + workflow_dispatch: + inputs: + pr_number: + description: Number of the Python pull request + required: true + type: string + dry_run: + description: List what would be done, change nothing (the App token is still made, which proves the installation) + type: boolean + default: true + +permissions: + contents: read + +# A newer push cancels the older run for the same pull request +concurrency: + group: linked-csharp-pr-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: true + +jobs: + prepare: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.pr.outputs.eligible }} + number: ${{ steps.pr.outputs.number }} + url: ${{ steps.pr.outputs.url }} + base_sha: ${{ steps.pr.outputs.base_sha }} + head_sha: ${{ steps.pr.outputs.head_sha }} + closed: ${{ steps.pr.outputs.closed }} + merged: ${{ steps.pr.outputs.merged }} + dry_run: ${{ steps.pr.outputs.dry_run }} + steps: + - name: Find out about the pull request + id: pr + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + INPUT_NUMBER: ${{ inputs.pr_number }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + if [ "$EVENT" = "workflow_dispatch" ]; then + data=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$INPUT_NUMBER") + dry_run=$INPUT_DRY_RUN + else + data=$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.pull_request.number }}") + dry_run=false + fi + + # Forks never receive the App secrets, so a pull request from a fork gets no linked C# pull request + if [ "$(echo "$data" | jq -r '.head.repo.full_name')" != "$GITHUB_REPOSITORY" ]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + echo "The pull request is from a fork: no linked C# pull request." + exit 0 + fi + + { + echo "eligible=true" + echo "number=$(echo "$data" | jq -r '.number')" + echo "url=$(echo "$data" | jq -r '.html_url')" + echo "base_sha=$(echo "$data" | jq -r '.base.sha')" + echo "head_sha=$(echo "$data" | jq -r '.head.sha')" + echo "closed=$([ "$(echo "$data" | jq -r '.state')" = closed ] && echo true || echo false)" + echo "merged=$(echo "$data" | jq -r '.merged')" + echo "dry_run=$dry_run" + } >> "$GITHUB_OUTPUT" + + repos: + needs: prepare + if: needs.prepare.outputs.eligible == 'true' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.repos.outputs.matrix }} + any: ${{ steps.repos.outputs.any }} + steps: + # The tooling comes from the base branch, so a pull request cannot change the code that holds the App token + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + path: base + + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.head_sha }} + path: head + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Which repositories does the change touch? + id: repos + shell: bash + env: + PYTHONPATH: base/.ci/generation + run: | + repos=$(python -m py2cs repos --base-root base/src/bhom_schema --head-root head/src/bhom_schema --allowed-repos base/Converted-repositories.txt) + echo "Repositories: $repos" + echo "matrix=$(echo "$repos" | jq -c '{include: map({repo: ., name: (split("/")[1])})}')" >> "$GITHUB_OUTPUT" + echo "any=$([ "$repos" != "[]" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + raise: + needs: [prepare, repos] + if: needs.prepare.outputs.closed == 'false' && needs.repos.outputs.any == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.repos.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + path: base + + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.head_sha }} + path: head + + # A token for this one repository, valid for about an hour. It is made on a dry run too, which proves the App is installed. + - name: Make a token for ${{ matrix.repo }} only + id: token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.BHOM_APP_ID }} + private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ matrix.name }} + + - name: Checkout the C# repository + uses: actions/checkout@v4 + with: + repository: ${{ matrix.repo }} + token: ${{ steps.token.outputs.token }} + path: csharp/${{ matrix.name }} + persist-credentials: false + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build the C# patcher + run: dotnet build base/.ci/generation/PythonSchemaGeneration.Patcher/PythonSchemaGeneration.Patcher.csproj -c Release --nologo + + - name: Plan the C# changes + shell: bash + env: + PYTHONPATH: base/.ci/generation + PY2CS_PATCHER: base/.ci/generation/PythonSchemaGeneration.Patcher/bin/Release/net8.0/PythonSchemaGeneration.Patcher.dll + run: | + python -m py2cs plan \ + --base-root base/src/bhom_schema --head-root head/src/bhom_schema \ + --clones-parent csharp --allowed-repos base/Converted-repositories.txt \ + --only-repo "${{ matrix.repo }}" \ + --python-pr "${{ github.repository }}#${{ needs.prepare.outputs.number }}" \ + --out plan + + # The tokens are passed to this step only, through the environment, and are never printed + - name: Push the branch and open or update the draft pull request + shell: bash + env: + PYTHONPATH: base/.ci/generation + PY2CS_CSHARP_TOKEN: ${{ steps.token.outputs.token }} + PY2CS_PYTHON_TOKEN: ${{ github.token }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} + run: | + flags="" + if [ "$DRY_RUN" = "true" ]; then flags="--dry-run"; fi + python -m py2cs pr --plan-dir plan --repo "${{ matrix.repo }}" \ + --python-repo "${{ github.repository }}" --python-pr "${{ needs.prepare.outputs.number }}" \ + --python-url "${{ needs.prepare.outputs.url }}" $flags + + close: + needs: [prepare, repos] + if: needs.prepare.outputs.closed == 'true' && needs.repos.outputs.any == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.repos.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + path: base + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Make a token for ${{ matrix.repo }} only + id: token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.BHOM_APP_ID }} + private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ matrix.name }} + + # Unmerged: the linked draft is closed with a comment. Merged: only a comment. + - name: Close or comment on the linked pull request + shell: bash + env: + PYTHONPATH: base/.ci/generation + PY2CS_CSHARP_TOKEN: ${{ steps.token.outputs.token }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} + MERGED: ${{ needs.prepare.outputs.merged }} + run: | + flags="" + if [ "$DRY_RUN" = "true" ]; then flags="$flags --dry-run"; fi + if [ "$MERGED" = "true" ]; then flags="$flags --merged"; fi + python -m py2cs close --repo "${{ matrix.repo }}" --python-repo "${{ github.repository }}" \ + --python-pr "${{ needs.prepare.outputs.number }}" $flags + + # After the draft is raised or updated: the status of the Python PR is pending until the C# pull request is merged + status: + needs: [prepare, repos, raise] + # After a raise, or when the change needs no C# change at all + if: needs.raise.result == 'success' || (needs.repos.result == 'success' && needs.repos.outputs.any == 'false' && needs.prepare.outputs.closed == 'false') + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + path: base + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set the commit status + shell: bash + env: + PYTHONPATH: base/.ci/generation + PY2CS_PYTHON_TOKEN: ${{ github.token }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} + run: | + flags="" + if [ "$DRY_RUN" = "true" ]; then flags="--dry-run"; fi + if [ "${{ needs.repos.outputs.any }}" = "false" ]; then flags="$flags --no-csharp-change"; fi + python -m py2cs status --python-repo "${{ github.repository }}" --python-pr "${{ needs.prepare.outputs.number }}" \ + --sha "${{ needs.prepare.outputs.head_sha }}" --repos-file base/Converted-repositories.txt $flags + + # Every hour: has the C# pull request of an open Python PR been merged (or closed) since? + # This only reads the C# repositories, which must be public, and writes statuses on this repository. + sweep: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + statuses: write + steps: + # The default branch, so the tooling is the trusted one + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Update the status of every open Python PR that changes the classes + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PYTHONPATH: .ci/generation + PY2CS_PYTHON_TOKEN: ${{ github.token }} + run: | + gh pr list --state open --limit 100 --json number,headRefOid,headRepositoryOwner,files \ + --jq '.[] | select(.headRepositoryOwner.login == "${{ github.repository_owner }}") | select(any(.files[]; .path | startswith("src/bhom_schema/"))) | "\(.number) \(.headRefOid)"' | + while read -r number sha; do + echo "Python PR #$number" + python -m py2cs status --python-repo "${{ github.repository }}" --python-pr "$number" --sha "$sha" --repos-file Converted-repositories.txt + done diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..4e9897c --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,95 @@ +name: PR checks + +on: + pull_request: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: windows-latest + env: + # Real-type tests fail instead of being skipped when an assembly is missing + CI: 'true' + PYTHONPATH: ${{ github.workspace }}/.ci/generation + # The test classes are cloned into .repos, not next to this repository + CSharpSchemaTestRoot: ${{ github.workspace }}\.repos\CSharpSchema_Test\ + BHOM_JSONSCHEMA_ROOT: ${{ github.workspace }}\.repos\BHoM_JSONSchema + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - uses: microsoft/setup-msbuild@v2 + + - uses: NuGet/setup-nuget@v2 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Clone the repositories + shell: pwsh + run: ./.github/scripts/Clone-Repositories.ps1 -List Included-repositories.txt + + - name: Build the repositories in dependency order + shell: pwsh + run: ./.github/scripts/Build-Repositories.ps1 -List Included-repositories.txt + + # The published JSON schemas are the test oracle for the JSON that Python writes. Read only; the generator does not use them. + - name: Clone BHoM_JSONSchema (test oracle) + shell: pwsh + run: git clone https://github.com/BHoM/BHoM_JSONSchema.git .repos/BHoM_JSONSchema --depth 1 --quiet + + - name: Create the virtual environment + shell: pwsh + run: | + python -m venv .venv + .venv\Scripts\python -m pip install -e ".[dev]" + + # Build, NUnit, py2cs drafts and the Roslyn round trip, pytest, and Pyright + - name: Build and test + shell: pwsh + run: ./.ci/unit-tests/run-all.ps1 + + - name: Does the Python PR change the classes? + id: changed + shell: pwsh + if: github.event_name == 'pull_request' + run: | + $base = "${{ github.event.pull_request.base.sha }}" + $changed = git diff --name-only $base HEAD -- src/bhom_schema + "classes=$([bool]$changed)".ToLower() | Out-File $env:GITHUB_OUTPUT -Append + + - name: Get the classes before the change + if: steps.changed.outputs.classes == 'true' + shell: pwsh + run: git worktree add "$env:RUNNER_TEMP/base" ${{ github.event.pull_request.base.sha }} + + - name: py2cs check (every problem of the changed Python, descriptions) + if: steps.changed.outputs.classes == 'true' + shell: pwsh + run: .venv\Scripts\python -m py2cs check --root src/bhom_schema --base-root "$env:RUNNER_TEMP/base/src/bhom_schema" + + # The Python PR is a proposal: it only passes once the linked C# PR is merged, because the clones above are the C# develop branches. + # After the C# PR is merged, re-run this workflow. + - name: py2cs align (fails until the linked C# PR is merged) + if: steps.changed.outputs.classes == 'true' + shell: pwsh + run: | + .venv\Scripts\python -m py2cs align ` + --base-root "$env:RUNNER_TEMP/base/src/bhom_schema" ` + --head-root src/bhom_schema ` + --assemblies "$env:ProgramData\BHoM\Assemblies" diff --git a/.github/workflows/python-schema-generation.yml b/.github/workflows/python-schema-generation.yml new file mode 100644 index 0000000..c75d4c4 --- /dev/null +++ b/.github/workflows/python-schema-generation.yml @@ -0,0 +1,111 @@ +name: Python schema generation + +# Rewrites src/bhom_schema from the C# oM, straight on develop. It is not a pull request: the C# is the source of truth, so a +# change in C# simply becomes a change in the Python. If any check fails, nothing is committed. + +on: + schedule: + - cron: '0 4 * * 6' # Saturday at 4 AM UTC, after the BHoM_JSONSchema job (1 AM); the two are independent + workflow_dispatch: + inputs: + dry_run: + description: Generate and check, but do not commit + type: boolean + default: false + +# Only one generation at a time +concurrency: + group: python-schema-generation + cancel-in-progress: false + +# Raised for the commit step only +permissions: + contents: write + +jobs: + generate: + runs-on: windows-latest + env: + PYTHONPATH: ${{ github.workspace }}/.ci/generation + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - uses: microsoft/setup-msbuild@v2 + + - uses: NuGet/setup-nuget@v2 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # BHoM, BHoM_Engine and JSONSchema_Toolkit give the generator what it references; CSharpSchema_Test holds the classes that are converted. + # They are cloned from GitHub (read only), never taken from a laptop, so the result does not depend on anybody's machine. + - name: Clone the repositories + shell: pwsh + run: ./.github/scripts/Clone-Repositories.ps1 -List Included-repositories.txt + + - name: Build the repositories in dependency order + shell: pwsh + run: ./.github/scripts/Build-Repositories.ps1 -List Included-repositories.txt + + - name: Build the generator + run: dotnet build .ci/generation/PythonSchemaGeneration/PythonSchemaGeneration.csproj -c Release --nologo + + - name: Generate src/bhom_schema + shell: pwsh + run: | + $repos = (Get-Content Converted-repositories.txt | Where-Object { $_.Trim() -ne "" -and -not $_.StartsWith("#") } | ForEach-Object { $_.Trim() }) -join "," + Write-Host "Converting: $repos" + dotnet .ci/generation/PythonSchemaGeneration/bin/Release/net8.0/PythonSchemaGeneration.dll ` + --output src/bhom_schema ` + --repos $repos ` + --manifest "$env:RUNNER_TEMP/manifest.json" ` + --report "$env:RUNNER_TEMP/report.txt" ` + --baseline-warnings .ci/generation/warnings-baseline.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Install the Python tools + run: python -m pip install -e ".[dev]" + + - name: Every module imports, and the Python reads back as the C# says + run: python -m py2cs verify --package src/bhom_schema --manifest "$env:RUNNER_TEMP/manifest.json" + + - name: Pyright + run: python -m pyright + + - name: Upload the generation report + uses: actions/upload-artifact@v4 + with: + name: generation-report + path: | + ${{ runner.temp }}/report.txt + ${{ runner.temp }}/manifest.json + + - name: Commit to develop + if: ${{ !inputs.dry_run }} + shell: pwsh + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Generated files are written with LF line endings; keep them as they are + git config core.autocrlf false + git add src/bhom_schema + git diff --cached --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host "No changes: the Python already matches the C#." + exit 0 + } + + $commits = (Get-Content .repos/commits.txt | ForEach-Object { "- $_" }) -join "`n" + $runUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" + git commit -m "Generate bhom_schema from the C# oM" -m "Built from:`n$commits" -m "Workflow run: $runUrl" + git push origin HEAD:develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cafe81c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release + +# Makes a release by hand, at the end of a milestone, like BHoM_JSONSchema: a branch vX.Y with the regenerated classes, the tag vX.Y.0, a GitHub +# Release with the wheel and the release notes, and the wheel on TestPyPI. The branch is left alone afterwards. If any check fails, nothing is pushed. + +on: + workflow_dispatch: + inputs: + version: + description: The BHoM version to release, as Major.Minor (for example 10.0). It must be the version the built BHoM.dll reports + type: string + required: true + dry_run: + description: Generate, check and build the wheel, but do not push, release or publish + type: boolean + default: true + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + permissions: + contents: write + env: + PYTHONPATH: ${{ github.workspace }}/.ci/generation + + steps: + - name: The version has the form Major.Minor + shell: pwsh + run: | + if ("${{ inputs.version }}" -notmatch '^\d+\.\d+$') { + Write-Error "The version must be Major.Minor, for example 10.0" + exit 1 + } + + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + + - name: The release does not exist yet + shell: pwsh + run: | + $existing = git ls-remote --heads --tags origin "v${{ inputs.version }}" "v${{ inputs.version }}.0" + if ($existing) { + Write-Error "v${{ inputs.version }} already exists: a release is never made twice. Take the next Major.Minor" + exit 1 + } + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - uses: microsoft/setup-msbuild@v2 + + - uses: NuGet/setup-nuget@v2 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # Cloned from GitHub (read only), never taken from a laptop + - name: Clone the repositories + shell: pwsh + run: ./.github/scripts/Clone-Repositories.ps1 -List Included-repositories.txt + + - name: Build the repositories in dependency order + shell: pwsh + run: ./.github/scripts/Build-Repositories.ps1 -List Included-repositories.txt + + - name: Build the generator + run: dotnet build .ci/generation/PythonSchemaGeneration/PythonSchemaGeneration.csproj -c Release --nologo + + # --release fails the step when the version is not the BHoM version that was read + - name: Generate src/bhom_schema for the release + shell: pwsh + run: | + $repos = (Get-Content Converted-repositories.txt | Where-Object { $_.Trim() -ne "" -and -not $_.StartsWith("#") } | ForEach-Object { $_.Trim() }) -join "," + dotnet .ci/generation/PythonSchemaGeneration/bin/Release/net8.0/PythonSchemaGeneration.dll ` + --output src/bhom_schema ` + --repos $repos ` + --release "${{ inputs.version }}" ` + --manifest "$env:RUNNER_TEMP/manifest.json" ` + --report "$env:RUNNER_TEMP/report.txt" ` + --baseline-warnings .ci/generation/warnings-baseline.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Install the Python tools + run: python -m pip install -e ".[dev]" build + + - name: Every module imports, and the Python reads back as the C# says + run: python -m py2cs verify --package src/bhom_schema --manifest "$env:RUNNER_TEMP/manifest.json" + + - name: Pyright + run: python -m pyright + + - name: Build the wheel + run: python -m build --wheel --outdir dist + + - name: Write the release notes + shell: pwsh + run: | + $previous = git tag --list "v*.0" --sort=-v:refname | Select-Object -First 1 + $arguments = @("notes", "--head-root", "src/bhom_schema", "--version", "${{ inputs.version }}.0", "--out", "$env:RUNNER_TEMP/notes.md") + if ($previous) { + git worktree add "$env:RUNNER_TEMP/previous" $previous + $arguments += @("--base-root", "$env:RUNNER_TEMP/previous/src/bhom_schema", "--previous", $previous) + } + python -m py2cs @arguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # One folder, so the files keep a flat layout in the artifact + - name: Upload the wheel and the notes + shell: pwsh + run: | + New-Item -ItemType Directory staging | Out-Null + Copy-Item dist/*.whl, "$env:RUNNER_TEMP/notes.md", "$env:RUNNER_TEMP/report.txt" staging + + - uses: actions/upload-artifact@v4 + with: + name: release + path: staging + + - name: Commit to the release branch, tag and release + if: ${{ !inputs.dry_run }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Generated files are written with LF line endings; keep them as they are + git config core.autocrlf false + git checkout -b "v${{ inputs.version }}" + git add src/bhom_schema + git commit --allow-empty -m "Release v${{ inputs.version }}.0" -m "Generated from the BHoM ${{ inputs.version }} oM." + git tag "v${{ inputs.version }}.0" + git push origin "v${{ inputs.version }}" "v${{ inputs.version }}.0" + gh release create "v${{ inputs.version }}.0" (Get-Item staging/*.whl).FullName --title "BHoM schema ${{ inputs.version }}.0" --notes-file "$env:RUNNER_TEMP/notes.md" + + # Trusted publishing: TestPyPI trusts this workflow and environment, so there is no token or password to store + publish-testpypi: + needs: build + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + + steps: + - name: Download the wheel + uses: actions/download-artifact@v4 + with: + name: release + path: release + + - name: Collect the wheel + run: mkdir dist && cp release/*.whl dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index c59cdfd..53234de 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,22 @@ bld/ *.suo *.user .vs/ + + +# Python # +########## +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.pyright/ +*.egg-info/ +build/ +dist/ + +# Generator scratch output # +############################ +.ci/unit-tests/_output/ +.ci/unit-tests/**/TestResults/ +/.claude diff --git a/Converted-repositories.txt b/Converted-repositories.txt new file mode 100644 index 0000000..05fda51 --- /dev/null +++ b/Converted-repositories.txt @@ -0,0 +1,3 @@ +# Repositories whose C# classes are converted to Python in src/bhom_schema, and where linked C# pull requests may be raised. +# Nothing else is written to: a Python pull request cannot aim a C# pull request at any other repository. +BHoM/CSharpSchema_Test diff --git a/Included-repositories.txt b/Included-repositories.txt new file mode 100644 index 0000000..bb1f24f --- /dev/null +++ b/Included-repositories.txt @@ -0,0 +1,4 @@ +BHoM/BHoM +BHoM/BHoM_Engine +BHoM/JSONSchema_Toolkit +BHoM/CSharpSchema_Test diff --git a/PowershellScript.ps1 b/PowershellScript.ps1 deleted file mode 100644 index cd3be30..0000000 --- a/PowershellScript.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -using namespace System.IO -using namespace System.Collections.Generic - -$softwareName = Read-Host "`nPlease enter the software name." - -try -{ - # Replace occurrences of "SoftwareName" in all files - Write-Host "`nReplacing occurrences of 'SoftwareName' with "$softwareName" in all files.`n" - - $replace_successful = $true - Get-ChildItem -File -Recurse | ForEach-Object { - try { - If ((Get-Content $_.Extension -eq ".ps1") -or (Get-Content $_.Extension -eq ".bat")) - { - continue - } - } catch {} - - try - { - (Get-Content $_.FullName) -replace 'SoftwareName', $softwareName | Set-Content $_.FullName - } - catch - { - Write-Host "`nAn error occurred when replacing file contents of " $_.FullName ":" - Write-Host $_ - $replace_successful = $false - } - } - - If($replace_successful) - { - Write-Host "Successfully replaced files contents.`n" - } - else - { - Write-Host "`nERROR: Could not replace some file contents.`n" - } - - - Write-Host "`nRenaming files and directories using "$softwareName":`n" - - - # Rename files and folders - $stack = [Stack[string]]::new() - $allPaths = [List[string]]::new() - - - # Get all files and directories containing "SoftwareName" recursively - Get-ChildItem -Recurse -Directory | ForEach-Object { - $dirpath = $_.FullName - $dirname = Split-Path $dirpath -Leaf - if ($dirname.Contains("SoftwareName")) - { - $stack.Push($dirpath) - $allPaths.Add($dirpath) - } - - # Write-Host $_.FullName - - foreach ($file in [Directory]::EnumerateFiles($dirpath)) - { - $filename = [Path]::GetFileName($file) - if ($filename.Contains('SoftwareName') -and -not $allPaths.Contains($file)) - { - $stack.Push($file) - $allPaths.Add($file) - } - } - } - - # Add root files - Get-ChildItem -File | ForEach-Object { - if ($_.FullName.Contains("SoftwareName")) { - $stack.Push($_.FullName) - } - } - - # Rename files and folders - while ($stack.Count) { - $poppedFullName = $stack.Pop() - $pathExists = (-not ([string]::IsNullOrEmpty($poppedFullName))) -and (Test-Path -Path $poppedFullName) - - $filename = [Path]::GetFileName($poppedFullName) - - if($filename.Contains('SoftwareName') -and $pathExists) - { - $newName = $filename.Replace('SoftwareName', $softwareName) - - Write-Host "Renaming: " $poppedFullName " to: " $newName - - Rename-Item -LiteralPath $poppedFullName -NewName $newName #-WhatIf - } - } - - Write-Host "`nAll files and folders renamed successfully." -} -catch -{ - Write-Host "`nERROR:" - Write-Host $_ -} - diff --git a/README.md b/README.md index f7f14fd..29d5b66 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,105 @@ -# template-repository -This repository can be used to create brand new BHoM Toolkits :rocket: +# BHoM_PythonSchema -- Just click on the green button above "Use this template" :point_up_2: . Call the new repo with the name of your software followed by `_Toolkit`, e.g. "MySoftware_Toolkit". If you want to develop outside of a repository, you can download the code by clicking "Code" and then saving it as zip. -- Go in your repository or unzipped folder, double click the `RenameToolkitFiles.bat` file. When asked, insert the name of your software, e.g. "MySoftware". All the files and folder will be renamed accordingly. -- Start developing! :rocket: +Python (Pydantic) classes generated from the BHoM C# object model (oM), the Python counterpart of +[BHoM_JSONSchema](https://github.com/BHoM/BHoM_JSONSchema). -## Configure development environment -Learn how to [set up your development environment for BHoM here](https://bhom.xyz/documentation/Guides-and-Tutorials/Coding-with-BHoM/). +- `src/bhom_schema/`: the generated Python package (`bhom_schema...`). +- `.ci/generation/PythonSchemaGeneration*`: the C# to Python converter (reads the compiled oM assemblies). +- `.ci/generation/py2cs`: the Python to C# converter, used by the linked-PR workflow to draft aligned C#. +- `.ci/unit-tests`: NUnit and pytest tests, plus `run-all.ps1` that runs everything. +- `.github/workflows`: the weekly generation, the PR checks and the linked C# pull request. See [docs/Workflows.md](docs/Workflows.md). +- `docs/`: research notes and the coding plan. -## Implement the Toolkits -See the instructions for [creating a new toolkit here](https://bhom.xyz/documentation/Contributing/Implementing-a-new-Toolkit/). +## Development setup + +Prerequisites: .NET 8 SDK, Python 3.12, a clone of [CSharpSchema_Test](https://github.com/BHoM/CSharpSchema_Test) next to this repository (the C# classes the tests use), and the BHoM assemblies installed in `%ProgramData%\BHoM\Assemblies` +(build the repositories in `Included-repositories.txt`: `BHoM`, `BHoM_Engine`, `JSONSchema_Toolkit` and `CSharpSchema_Test`). + +```powershell +python -m venv .venv +.venv\Scripts\python -m pip install -e ".[dev]" +.ci\unit-tests\run-all.ps1 +``` + +`run-all.ps1` starts with a preflight that reports missing assemblies. It never builds a repo for you. + +## Installing and versions + +The version of the package is the BHoM version of its classes: `bhom_schema.__bhom_version__` is `10.0` and `bhom_schema.__version__` is +`10.0.0` for a release. `develop` is regenerated every week and is `10.0.0.dev0`. A release is made by hand, like BHoM_JSONSchema's: the +*Release* workflow makes the branch `v10.0` and the tag `v10.0.0`, and attaches the wheel and the release notes (with a "Breaking changes" +section) to a GitHub Release. The wheel is also published to TestPyPI: + +```powershell +pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ bhom-schema +``` + +To upgrade, read the release notes first. See [docs/Workflows.md](docs/Workflows.md) for how a release is made. + +## Saving and loading JSON + +The classes read and write the JSON that [BHoM_JSONSchema](https://github.com/BHoM/BHoM_JSONSchema) describes, the same as BHoM in C#: + +```python +from bhom_schema.FixtureAlpha_oM.GeometryHolder import GeometryHolder + +text = holder.model_dump_json() # every object carries "_t", its C# class name +GeometryHolder.model_validate_json(text) # an interface field (Geometry: IGeometryLike) loads the class that "_t" names +GeometryHolder.model_json_schema() # the JSON Schema of the class +``` + +A value in an interface field needs `_t`, and only classes that are in `bhom_schema` are accepted. Enums are written by name. See +[docs/audit-1/Fix-Plan.md](docs/audit-1/Fix-Plan.md) (step 5) for the rules and their limits. + +## Changing the Python classes + +A change to `src/bhom_schema` in a pull request is turned into a draft C# pull request by `py2cs`. It reads the Python +without running it, so it only understands these rules: + +- **One class per file**, in `src/bhom_schema///.py`, inside an assembly folder that + already exists (for example `Structure_oM/Elements/Bar.py` for `BH.oM.Structure.Elements.Bar`). +- **A new class needs no `__bhom__` marker**: its C# name, namespace and repository are worked out from the folder and the + generated classes next to it, and the PR report says so. +- **Every field has a default** where C# would have one: `Name: str = ""`, `Stage: int = Field(0, description="...")`, + `Tags: list[str] = Field(default_factory=list)`. A field without a default becomes a C# property without an initial value. +- **Every class and field has a description**: a docstring on the class, `Field(description="...")` on a field. +- **Supported types**: `str`, `int`, `float`, `bool`, `Decimal`, `datetime`, `timedelta`, `UUID`, `Any`, `list[...]`, + `dict[...]`, `set[...]`, `tuple[...]`, `X | None` (or `Optional[X]`), and other `bhom_schema` classes. Use + `Annotated[int, ClrType("long")]` when the C# type is not the usual one, and `Quantity("Length", "m")` for physical quantities. +- **Existing C# files are changed member by member**: only the properties that changed in Python are touched. + +[docs/templates/NewClass.py](docs/templates/NewClass.py) is a class to copy. Check your change before pushing: + +```powershell +$env:PYTHONPATH = ".ci/generation" +.venv\Scripts\python -m py2cs check --root src/bhom_schema +``` + +## Life of a Python change + +The C# is the source of truth. A Python pull request is a **proposal**, and it is linked to a C# pull request in the +repository that owns the class. + +1. Open the Python PR. `py2cs` drafts the linked C# PR (a draft, with a report). The Python PR is **never merged before its + C# PR**. +2. The C# PR is reviewed and merged in its own repository, as usual. Reviewers may change it. +3. Before the Python PR can be merged, the **alignment check** must pass. It generates the Python from the merged C# and + compares it with the PR. If they differ, change the Python PR to match what was merged. Or close the Python PR and let the + next weekly generation bring in the same change; both give the same files. +4. If the C# PR is closed without merging, close the Python PR too, with a comment saying why. Otherwise the next weekly + generation removes the change from the Python again. + +```powershell +# After the C# is merged and built (the folder holds the built oM dlls) +.venv\Scripts\python -m py2cs align --base-root --head-root src/bhom_schema --assemblies +``` + +It exits with 0 when they match, and lists every difference otherwise (the first value is from the C#, the second from your PR). + +**Breaking changes** are listed in their own section of the C# PR, which also gets the `breaking-change` label. They are: +a property removed or renamed, a property whose type changed, an enum member removed or renumbered, a class removed, and a base +class or interface removed. BHoM needs an upgrade in Versioning_Toolkit for these so old files still open. Nothing is blocked +automatically; the reviewer decides. + +The weekly generation writes the Python straight from the C# and commits it to `develop`. It is not a pull request, so it never +raises a C# PR. diff --git a/RenameToolkitFiles.bat b/RenameToolkitFiles.bat deleted file mode 100644 index a4295cb..0000000 --- a/RenameToolkitFiles.bat +++ /dev/null @@ -1,2 +0,0 @@ -PowerShell -NoProfile -ExecutionPolicy Bypass -Command "& './PowershellScript.ps1'" -pause \ No newline at end of file diff --git a/SoftwareName_Adapter/CRUD/Create/_ICreate.cs b/SoftwareName_Adapter/CRUD/Create/_ICreate.cs deleted file mode 100644 index e2ec883..0000000 --- a/SoftwareName_Adapter/CRUD/Create/_ICreate.cs +++ /dev/null @@ -1,79 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapter; -using BH.oM.Base; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public partial class SoftwareNameAdapter : BHoMAdapter - { - // NOTE: CRUD folder methods - // All methods in the CRUD folder are used as "back-end" methods by the Adapter itself. - // They are automatically invoked by the Adapter Actions (Push, Pull, etc.). - // Specifically, the Create is primarily called by the Push (in the context of the CRUD method, and also by other methods that require it: Update, UpdateProperty). - // See the wiki for more information. - - // The Create should only contain the logic that generates the objects in the external software. - protected override bool ICreate(IEnumerable objects, ActionConfig actionConfig = null) - { - bool success = true; - - // Preferrably, different Create logic for different object types should go in separate methods. - // We achieve this by using the ICreate method to only dynamically dispatching to *type-specific Create implementations* - // In other words: - foreach (T obj in objects) - { - success &= Create(obj as dynamic); - } - - // Then place the specific Create methods below this method or, better, in separate file for each object type. - return success; - } - - // Write your type-specific implementations of Create like: - // protected bool Create(IEnumerable node) //`Node` is just an example BHoM Type. - // { - // // Code to do the Create of `Node`s, including: - // // - calling `Convert` methods from the BHoM type to the external object model - // // (Convert methods should be defined in the specific `Convert` folder); - // // - any API call that do the actual export to the external software. - // return true; // if successfull - // } - - /***************************************************/ - - // Fallback case. If no specific Create is found, here we should handle what happens then. - protected bool Create(IBHoMObject obj) - { - BH.Engine.Base.Compute.RecordError("No specific Create method found for {obj.GetType().Name}."); - return false; - } - } -} - - diff --git a/SoftwareName_Adapter/CRUD/Delete/_IDelete.cs b/SoftwareName_Adapter/CRUD/Delete/_IDelete.cs deleted file mode 100644 index 8053627..0000000 --- a/SoftwareName_Adapter/CRUD/Delete/_IDelete.cs +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapter; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public partial class SoftwareNameAdapter : BHoMAdapter - { - // Basic Delete method that deletes objects depending on their Type and Id. - // It gets called by the Push or by the Remove Adapter Actions. - // Its implementation is facultative (not needed for a simple export/import scenario). - // Toolkits need to implement (override) this only to get the full CRUD to work. - protected override int IDelete(Type type, IEnumerable ids, ActionConfig actionConfig = null) - { - //Insert code here to enable deletion of specific types of objects with specific ids - BH.Engine.Base.Compute.RecordError($"Delete for objects of type {type.Name} is not implemented in {(this as dynamic).GetType().Name}."); - return 0; - } - - // There are more virtual Delete methods you might want to override and implement. - // Check the base BHoM_Adapter solution and the wiki for more info. - - /***************************************************/ - } -} - - diff --git a/SoftwareName_Adapter/CRUD/Read/_IRead.cs b/SoftwareName_Adapter/CRUD/Read/_IRead.cs deleted file mode 100644 index 1533448..0000000 --- a/SoftwareName_Adapter/CRUD/Read/_IRead.cs +++ /dev/null @@ -1,62 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapter; -using BH.oM.Base; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public partial class SoftwareNameAdapter : BHoMAdapter - { - /***************************************************/ - /**** Adapter overload method ****/ - /***************************************************/ - - // This method gets called when appropriate by the Pull method contained in the base Adapter class. - // It gets called once per each Type. - protected override IEnumerable IRead(Type type, IList ids, ActionConfig actionConfig = null) - { - // Preferrably, different Create logic for different object types should go in separate methods. - // We achieve this by using the ICreate method to only dynamically dispatching to *type-specific Create implementations* - // In other words: - // if (type == typeof(SomeType1)) - // return ReadSomeType1(ids as dynamic); - // else if (type == typeof(SomeType2)) - // return ReadSomeType2(ids as dynamic); - // else if (type == typeof(SomeType3)) - // return ReadSomeType3(ids as dynamic); - - return new List(); - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Adapter/CRUD/Update/_IUpdate.cs b/SoftwareName_Adapter/CRUD/Update/_IUpdate.cs deleted file mode 100644 index 55d8272..0000000 --- a/SoftwareName_Adapter/CRUD/Update/_IUpdate.cs +++ /dev/null @@ -1,51 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapter; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public partial class SoftwareNameAdapter : BHoMAdapter - { - // This method gets called when appropriate by the Push method contained in the base Adapter class. - // Unlike the Create, Delete and Read, this method already exposes a simple implementation: it calls Delete and then Create. - // It can be overridden here keeping in mind the following: - // - it gets called once per each Type, and if equal objects are found; - // - the object equality is tested through this.AdapterComparers, that need to be implemented for each type. - // See the wiki for more info. - - protected override bool IUpdate(IEnumerable objects, ActionConfig actionConfig = null) - { - return base.IUpdate(objects, actionConfig); - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Adapter/Convert/FromSoftwareName/ExampleObject.cs b/SoftwareName_Adapter/Convert/FromSoftwareName/ExampleObject.cs deleted file mode 100644 index bbde145..0000000 --- a/SoftwareName_Adapter/Convert/FromSoftwareName/ExampleObject.cs +++ /dev/null @@ -1,51 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapters.SoftwareName; -using BH.oM.Base; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public static partial class Convert - { - /***************************************************/ - /**** Public Methods ****/ - /***************************************************/ - - // Add methods for converting to BHoM from the specific software types. - // Example: - public static BHoMObject FromSoftwareName(this ExampleObject node) - { - //Insert code for convertion - throw new NotImplementedException(); - } - - /***************************************************/ - } -} - - diff --git a/SoftwareName_Adapter/SoftwareNameAdapter.cs b/SoftwareName_Adapter/SoftwareNameAdapter.cs deleted file mode 100644 index 1790dd9..0000000 --- a/SoftwareName_Adapter/SoftwareNameAdapter.cs +++ /dev/null @@ -1,71 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.Adapter; -using BH.oM.Base.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BH.Adapter.SoftwareName -{ - public partial class SoftwareNameAdapter : BHoMAdapter - { - /***************************************************/ - /**** Constructors ****/ - /***************************************************/ - - [Description("Adapter for SoftwareName.")] - [Output("The created SoftwareName adapter.")] - public SoftwareNameAdapter() - { - // The Adapter constructor can be used to configure the Adapter behaviour. - // For example: - m_AdapterSettings.DefaultPushType = oM.Adapter.PushType.CreateOnly; // Adapter `Push` Action simply calls "Create" method. - - // See the wiki, the AdapterSettings object and other Adapters to see how it can be configured. - - // If your toolkit needs to define this.AdapterComparers and or this.DependencyTypes, - // this constructor has to populate those properties. - // See the wiki for more information. - } - - // You can add any other constructors that take more inputs here. - - /***************************************************/ - /**** Private Fields ****/ - /***************************************************/ - - // You can add any private variable that should be in common to any other adapter methods here. - // If you need to add some private methods, please consider first what their nature is: - // if a method does not need any external call (API call, connection call, etc.) - // we place them in the Engine project, and then reference them from the Adapter. - // See the wiki for more information. - - /***************************************************/ - } -} - - diff --git a/SoftwareName_Adapter/SoftwareName_Adapter.csproj b/SoftwareName_Adapter/SoftwareName_Adapter.csproj deleted file mode 100644 index 6c8779b..0000000 --- a/SoftwareName_Adapter/SoftwareName_Adapter.csproj +++ /dev/null @@ -1,58 +0,0 @@ - - - - netstandard2.0 - SoftwareName_Adapter - ..\Build\ - 6.0.0.0 - https://github.com/BHoM/SoftwareName_Toolkit - 5.0.0 - BHoM - Copyright © https://github.com/BHoM - BH.Adapter.SoftwareName - 6.1.0.0 - true - - - - - - - - - - - - - - C:\ProgramData\BHoM\Assemblies\Adapter_Engine.dll - false - false - - - C:\ProgramData\BHoM\Assemblies\Adapter_oM.dll - false - false - - - C:\ProgramData\BHoM\Assemblies\BHoM.dll - false - false - - - C:\ProgramData\BHoM\Assemblies\BHoM_Adapter.dll - false - false - - - C:\ProgramData\BHoM\Assemblies\BHoM_Engine.dll - false - false - - - - - - - - diff --git a/SoftwareName_Engine/Compute/ExampleComputeMethod.cs b/SoftwareName_Engine/Compute/ExampleComputeMethod.cs deleted file mode 100644 index 7750a6f..0000000 --- a/SoftwareName_Engine/Compute/ExampleComputeMethod.cs +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Base; -using BH.oM.Base.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace BH.Engine.Adapters.SoftwareName -{ - public static partial class Compute - { - /***************************************************/ - /**** Public Methods ****/ - /***************************************************/ - - [Description("Description of the method. Will appear in the UI tooltip.")] - [Input("someInput1", "Description of the input. Will appear in the UI tooltip.")] - [Input("someInput2", "Description of the input. Will appear in the UI tooltip.")] - [Output("outputName", "Description of the output. Will appear in the UI tooltip.")] - public static void ExampleComputeMethod(string someInput1, string someInput2) - { - // This method will appear in every UI (e.g. Grasshopper) as a component. - // Find it using the CTRL+Shift+B search bar, or by navigating the `Compute` component (Engine tab) right click menu. - throw new NotImplementedException(); - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Engine/Create/ExampleCreateMethod.cs b/SoftwareName_Engine/Create/ExampleCreateMethod.cs deleted file mode 100644 index f2895ac..0000000 --- a/SoftwareName_Engine/Create/ExampleCreateMethod.cs +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapters.SoftwareName; -using BH.oM.Base; -using BH.oM.Base.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace BH.Engine.Adapters.SoftwareName -{ - public static partial class Create - { - /***************************************************/ - /**** Public Methods ****/ - /***************************************************/ - - [Description("Description of the method. Will appear in the UI tooltip.")] - [Input("someInput1", "Description of the input. Will appear in the UI tooltip.")] - [Input("someInput2", "Description of the input. Will appear in the UI tooltip.")] - [Output("outputName", "Description of the output. Will appear in the UI tooltip.")] - public static ExampleObject ExampleCreateMethod(string someInput1, int someInput2) - { - // This method will appear in every UI (e.g. Grasshopper) as a component. - // Find it using the CTRL+Shift+B search bar, or by navigating the `Create` component (Engine tab) right click menu. - return new ExampleObject() { SomeStringProperty = someInput1, SomeNumberProperty = someInput2 }; - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Engine/Modify/ExampleModifyMethod.cs b/SoftwareName_Engine/Modify/ExampleModifyMethod.cs deleted file mode 100644 index d8ca09a..0000000 --- a/SoftwareName_Engine/Modify/ExampleModifyMethod.cs +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Base; -using BH.oM.Base.Attributes; -using BH.oM.Adapters.SoftwareName; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace BH.Engine.Adapters.SoftwareName -{ - public static partial class Modify - { - /***************************************************/ - /**** Public Methods ****/ - /***************************************************/ - - [Description("Description of the method. Will appear in the UI tooltip.")] - [Input("objectToModify", "Description of the input. Will appear in the UI tooltip.")] - [Output("outputName", "Description of the output. Will appear in the UI tooltip.")] - public static ExampleObject ExampleCreateMethod(ExampleObject objectToModify) - { - // This method will appear in every UI (e.g. Grasshopper) as a component. - // Find it using the CTRL+Shift+B search bar, or by navigating the `Create` component (Engine tab) right click menu. - throw new NotImplementedException(); - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Engine/Query/ExampleQueryMethod.cs b/SoftwareName_Engine/Query/ExampleQueryMethod.cs deleted file mode 100644 index 88dfdf7..0000000 --- a/SoftwareName_Engine/Query/ExampleQueryMethod.cs +++ /dev/null @@ -1,59 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Adapters.SoftwareName; -using BH.oM.Base; -using BH.oM.Base.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace BH.Engine.Adapters.SoftwareName -{ - public static partial class Query - { - /***************************************************/ - /**** Public Methods ****/ - /***************************************************/ - - [Description("Description of the method. Will appear in the UI tooltip.")] - [Input("exampleObject", "Description of the input. Will appear in the UI tooltip.")] - [Input("additionalInput", "Description of the input. Will appear in the UI tooltip.")] - [Output("outputName", "Description of the output. Will appear in the UI tooltip.")] - public static string ExampleQueryMethod(this ExampleObject exampleObject, string additionalInput = "") - { - // NOTE: Extension method - // Query methods should return some data that is derivable from a main input object on which they operate upon. - // For this reason, they are to be written as extension methods (using the `this` keyword on the first input). - - // This method will appear in every UI (e.g. Grasshopper) as a component. - // Find it using the CTRL+Shift+B search bar, or by navigating the `Create` component (Engine tab) right click menu. - return exampleObject.SomeStringProperty + exampleObject.SomeNumberProperty.ToString() + additionalInput; - } - - /***************************************************/ - - } -} - - diff --git a/SoftwareName_Engine/SoftwareName_Engine.csproj b/SoftwareName_Engine/SoftwareName_Engine.csproj deleted file mode 100644 index 8bbace0..0000000 --- a/SoftwareName_Engine/SoftwareName_Engine.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - netstandard2.0 - ..\Build\ - 6.0.0.0 - https://github.com/BHoM/SoftwareName_Toolkit - 5.0.0 - BHoM - Copyright © https://github.com/BHoM - BH.Engine.Adapters.SoftwareName - 6.1.0.0 - true - - - - - - - - - C:\ProgramData\BHoM\Assemblies\BHoM.dll - false - false - - - - - - - - diff --git a/SoftwareName_Toolkit.sln b/SoftwareName_Toolkit.sln deleted file mode 100644 index 4d7481e..0000000 --- a/SoftwareName_Toolkit.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.32901.82 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SoftwareName_Adapter", "SoftwareName_Adapter\SoftwareName_Adapter.csproj", "{F84BEFC2-C702-4A86-9626-C7FA48785606}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareName_Engine", "SoftwareName_Engine\SoftwareName_Engine.csproj", "{C68B4D77-E46D-41C9-A72C-F29984D8AB4A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareName_oM", "SoftwareName_oM\SoftwareName_oM.csproj", "{FA23407E-1C08-4183-A02E-A57FA9D09BCA}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F84BEFC2-C702-4A86-9626-C7FA48785606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F84BEFC2-C702-4A86-9626-C7FA48785606}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F84BEFC2-C702-4A86-9626-C7FA48785606}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F84BEFC2-C702-4A86-9626-C7FA48785606}.Release|Any CPU.Build.0 = Release|Any CPU - {C68B4D77-E46D-41C9-A72C-F29984D8AB4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C68B4D77-E46D-41C9-A72C-F29984D8AB4A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C68B4D77-E46D-41C9-A72C-F29984D8AB4A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C68B4D77-E46D-41C9-A72C-F29984D8AB4A}.Release|Any CPU.Build.0 = Release|Any CPU - {FA23407E-1C08-4183-A02E-A57FA9D09BCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA23407E-1C08-4183-A02E-A57FA9D09BCA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA23407E-1C08-4183-A02E-A57FA9D09BCA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA23407E-1C08-4183-A02E-A57FA9D09BCA}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8FE775B3-08C4-442D-9FF5-5E3D16B383F5} - EndGlobalSection -EndGlobal diff --git a/SoftwareName_oM/ExampleObject.cs b/SoftwareName_oM/ExampleObject.cs deleted file mode 100644 index 34bb2c1..0000000 --- a/SoftwareName_oM/ExampleObject.cs +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2024, the respective contributors. All rights reserved. - * - * Each contributor holds copyright over their respective contributions. - * The project versioning (Git) records all such contribution source information. - * - * - * The BHoM is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3.0 of the License, or - * (at your option) any later version. - * - * The BHoM is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this code. If not, see . - */ - -using BH.oM.Base; -using BH.oM.Base.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace BH.oM.Adapters.SoftwareName -{ - [Description("Object description in here. Will appear in the UI tooltip.")] - public class ExampleObject : BHoMObject - { - // // See examples in the BHoM repo and the wiki to see how we define types. - // // Generally, all properties should be public and have public getter and setter. - // // BHoM Objects should have orthogonal properties and no behaviour (no methods), as in C# Records (or Python Dataclasses). - // // No constructor should be specified. If a specific instantiaton method is needed, we make it as an "Engine/Create" method. - // // Objects created with this convention will automatically appear as UI components (e.g. Grasshopper component). - - [Description("Property description in here.")] - public string SomeStringProperty { get; set; } - - [Description("Property description in here.")] - public int SomeNumberProperty { get; set; } - } -} - - diff --git a/SoftwareName_oM/SoftwareName_oM.csproj b/SoftwareName_oM/SoftwareName_oM.csproj deleted file mode 100644 index ad9587f..0000000 --- a/SoftwareName_oM/SoftwareName_oM.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - netstandard2.0 - ..\Build\ - 6.0.0.0 - https://github.com/BHoM/SoftwareName_Toolkit - 5.0.0 - BHoM - Copyright © https://github.com/BHoM - BH.oM.SoftwareName - 6.1.0.0 - true - - - - - C:\ProgramData\BHoM\Assemblies\BHoM.dll - false - false - - - - - - - - diff --git a/dependencies.txt b/dependencies.txt index c859174..7437a12 100644 --- a/dependencies.txt +++ b/dependencies.txt @@ -1,3 +1,3 @@ BHoM/BHoM BHoM/BHoM_Engine -BHoM/BHoM_Adapter \ No newline at end of file +BHoM/JSONSchema_Toolkit diff --git a/docs/CODING-PLAN.md b/docs/CODING-PLAN.md new file mode 100644 index 0000000..f9c786b --- /dev/null +++ b/docs/CODING-PLAN.md @@ -0,0 +1,531 @@ +# Coding plan: BHoM_PythonSchema converters + +Status: **plan only, no code written yet.** Background and evidence are in +[CSharp-to-Python-Gotchas.md](CSharp-to-Python-Gotchas.md); gotcha numbers below refer to that document. + +Revision 3: adds `ProjectIdentification` to the spike set, brings the CI work and the linked-PR Action into scope with the +existing GitHub App, uses the `BHoM/CSharpSchema_Test` repository for the rehearsal (the BHoM repositories are never written to), and records the decisions +taken so far (section 9). + +## Implementation status (phases 0 to 6 done, 7 and 8 not started) + +Run `.ci/unit-tests/run-all.ps1`; it builds, generates, drafts, round-trips, runs NUnit (93 tests), pytest (195 tests) and Pyright. + +Where the implementation differs from the plan above: + +- **Golden files are reviewed generator snapshots**, not hand-written specifications. The behaviour of the spike types is pinned by the pytest suites + (`test_fixtures`, `test_spike`); only a small, stable real type (`GradientCenteringOptions`) has a committed golden file, so a change in the + moving BHoM develop branch does not break the golden tests. The dKoP types (`ProjectIdentification`, `IdKoPObject`) were dropped from the + real-type tests when dKoP_Toolkit left the scope; the fixture classes `ProjectLike` and `ThingIdentity` cover the same cases. +- **Pytest suites are separate sessions** (`test_py2cs`, `test_fixtures`, `test_spike`, `test_alignment`) because the fixture and spike packages share assembly folder names such as `BHoM`. +- **Imports:** a type outside any reference cycle is imported at the top of the module; only cyclic references use bottom-of-module imports plus `model_rebuild()`. The spike set has no cycle; the fixtures + contain one on purpose. +- **Generated model files start with** `# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false`: Pyright cannot infer `Field(default_factory=list)` with a + `description`, and some C# classes redeclare a base property with another type. +- **Interface members** (names, types, descriptions) are kept in the `__bhom__` marker, because annotations on a plain base class would be collected by Pydantic as fields. Types they mention are generated too. +- **Required fields** (no default found because the C# type has no parameterless constructor) are drafted back as get-only properties plus a constructor. +- Same simple type names used by several types are written in full in the drafted C#, because an unqualified name can bind to a type in an enclosing namespace instead of the `using`. +- Drafts compile against `BHoM.dll` and `Quantities_oM.dll`; types the generator maps specially (for example `FragmentSet`) resolve from there. + +## 1. End goal + +1. **C# → Python converter** ready: reads the compiled BHoM oM assemblies and writes one Pydantic class per C# type. +2. **Python → C# converter** ready: reads the Python classes and drafts the aligned C# for the linked PR. +3. Both converters have their **entry points and core logic in `.ci/generation`**. They may reference C# logic from + other repos (BHoM, BHoM_Engine, JSONSchema_Toolkit), but nothing of the business logic lives elsewhere. +4. **Tests** that verify the spike set (research doc section 11: ten slots, eleven types) plus the tests the workflow in + the system-design diagram needs (section 6 below). +5. Generated `.py` files land in a **new Python project** in this repo (`bhom_schema`). +6. The repo's **template leftovers are removed**. +7. **CI is wired**: weekly schema generation, PR checks, and the automatic linked C# PR using the existing GitHub App. +8. Everything ends with a clean **build and full test run** (repo policy). + +BHoM JSON is in scope since audit 1 (step 5): the classes read and write the JSON that `BHoM_JSONSchema` describes (`_t`, interface fields, +enums by name). The weekly JSON export itself stays untouched and independent; its published schemas are only used as a test oracle. + +--- + +## 2. Target repository layout + +``` +BHoM_PythonSchema/ +├─ .github/ +│ ├─ ISSUE_TEMPLATE/ (kept, identical to the JSON repo's) +│ ├─ PULL_REQUEST_TEMPLATE.md (kept) +│ ├─ CODEOWNERS (new, copied from the JSON repo) +│ └─ workflows/ +│ ├─ build.ps1 (copied from the JSON repo) +│ ├─ python-schema-generation.yml weekly + manual: C# → Python, commits straight to develop (no PR) +│ ├─ pr-checks.yml build, NUnit, pytest, Pyright on every PR +│ └─ linked-csharp-pr.yml Python PR → draft C# PR(s), uses the GitHub App +├─ .ci/ +│ ├─ generation/ +│ │ ├─ PythonSchemaGeneration.sln +│ │ ├─ PythonSchemaGeneration/ C# console app, the C# → Python entry point +│ │ ├─ PythonSchemaGeneration.Core/ C# class library: all the real logic (testable) +│ │ └─ py2cs/ Python package, the Python → C# converter (python -m py2cs) +│ │ ├─ reader / diff / locate / place / emitter / report / resolve +│ │ └─ pr.py GitHub client interface + PR raiser (real client and mock client) +│ └─ unit-tests/ +│ ├─ csharp/ +│ │ ├─ PythonSchemaGeneration.Tests/ NUnit tests +│ │ └─ (FixtureAlpha_oM, FixtureBeta_oM: moved to the CSharpSchema_Test repository, cloned next to this one) +│ ├─ python/ pytest tests (converters, PR raiser, generated package) +│ ├─ golden/ hand-written expected .py / .cs / report outputs +│ ├─ github-fixtures/ recorded repository listings and API replies for offline tests +│ └─ run-all.ps1 one script that runs every stage end to end +├─ src/ +│ └─ bhom_schema/ the new Python project (generated output lands here) +│ ├─ _support/ small hand-written helpers (section 4.3) +│ └─ //.py e.g. dKoP_oM/ProjectIdentification.py (generated) +├─ pyproject.toml package metadata, pytest and pyright config, dev extras +├─ pyrightconfig.json strict type-checking rules shared by editor and CI +├─ Included-repositories.txt copied from the JSON repo: repos to clone and build before generating +├─ dependencies.txt kept, trimmed to what the converter really references +├─ README.md rewritten +└─ CSharp-to-Python-Gotchas.md, CODING-PLAN.md +``` + +Folder names follow the `.ci/generation` spelling you gave (the JSON repo uses `.ci/Generation`; git is case-sensitive so +we stay consistent from the start). + +### Template files to remove (phase 0) + +Everything is a BHoM *toolkit* template and unrelated to this repo: +`SoftwareName_Adapter/`, `SoftwareName_Engine/`, `SoftwareName_oM/`, `SoftwareName_Toolkit.sln`, +`PowershellScript.ps1`, `RenameToolkitFiles.bat`. Rewrite `README.md`. Trim `dependencies.txt` (currently lists +`BHoM_Adapter`, which we don't use). Keep `LICENSE`, `.github/ISSUE_TEMPLATE`, `.github/PULL_REQUEST_TEMPLATE.md`. +Extend `.gitignore`: it currently has no entries for `bin/`, `obj/`, `__pycache__/`, `.venv/`, `.pytest_cache/`, +`.pyright/`, or generated test output, so these must be added before the first build. + +--- + +## 3. How the pieces fit (data flow) + +``` + BHoM assemblies (ProgramData\BHoM\Assemblies, or built in CI) + │ + PythonSchemaGeneration (C#, reflection) + │ + ┌─────────────────────────────────┴───────────────────────────┐ + Type model (in memory) report + type manifest + │ (map types, order bases, add metadata + provenance) (warnings, counts; test input) + ▼ + src/bhom_schema/**/*.py ──────────────► Pydantic classes used by Python projects + + Python PR in this repo (not from the weekly job) + │ + py2cs check strict read of the changed Python: every problem listed per file and line, descriptions + │ checked; drafts nothing (a failing check stops the flow) + │ + py2cs diff reader (static, ast) → manifest at PR base and head → what changed. + │ A class without a __bhom__ marker is not skipped: its marker is worked out from the folder + │ and the generated classes next to it (the PR report says so) + │ + py2cs resolve repo(s) from each class's __bhom__["repo"] (validated through the GitHub App) + │ + py2cs locate find the existing .cs file by searching the C# repo (never by namespace) + │ + ├── type not in C# yet ──► emit_new: whole new file, placed by the `place` rules + │ + └── type exists in C# ──► change list (py2cs/patch.py) + │ + Roslyn patcher (PythonSchemaGeneration.Patcher) + changes only the listed members, rest of the file untouched + │ + py2cs plan files + change report (reviewer notes, breaking changes) written to a scratch folder, + │ never into the C# checkout (`plan` runs locate, emit and patch in one command) + │ + py2cs pr draft C# PR per repo, linked both ways [phase 8, not built yet] + + C# PR reviewed and merged, then built + │ + py2cs align generator runs for the changed classes over the built C#; its Python must equal the PR's + Python (the Python PR is a proposal and is only merged when this passes) +``` + +Both converters share one **language-neutral type manifest** (name, namespace, assembly, repo, kind, bases, generic +parameters, properties with C# type and metadata). C# → Python writes it next to the Python it generates; Python → C# +rebuilds it from Python. Structural tests compare two manifests, so each converter is testable without the other. + +Two parts of the flow are separate programs: py2cs is Python and only reads Python, and the Roslyn patcher is C# and +only reads C#. They talk through a JSON job file (type, namespace, usings, list of member changes), so the patcher needs +no knowledge of Python and py2cs needs no knowledge of Roslyn. + +--- + +## 4. Design + +### 4.1 C# → Python (`PythonSchemaGeneration.Core`) + +| Stage | Responsibility | Notes | +|---|---|---| +| `AssemblyLoader` | Find and load `*oM.dll` from the BHoM folder, organisation-filtered | Same pattern as the existing generator; reports loaded and skipped assemblies (gotcha 13) | +| `TypeSelector` | Choose oM types: enums, `IObject` implementers, not static, oM namespace | Same rules as `Generate.GenerateAssembly`, so the two repos cover the same types | +| `TypeExtractor` | Reflection → type model (declared properties only, ordered bases, generic info, metadata) | Classifies interface vs class by reflection, **never by name** (`IdKoPObject` has no `I`). Reads defaults by instantiating where a parameterless constructor exists (gotcha 3). Reduces and orders interface bases (gotcha 6) | +| `ProvenanceReader` | Reads `AssemblyDescription` for the repo, plus namespace and assembly | Normalises `https://github.com/BHoM/dKoP_Toolkit` → `BHoM/dKoP_Toolkit` (gotcha 12) | +| `TypeMapper` | C# type → Python type expression + required imports | One table (research doc, section 5). Unknowns become `Any` plus a warning (gotcha 11) | +| `NameSanitiser` | Python-safe names | ``Output`1`` → `Output_1`; enum `None` → `None_`; keyword/builtin shadow checks (gotchas 5, 7) | +| `PythonEmitter` | Type model → `.py` text | Deterministic: sorted members, LF line endings, fixed header, generated-file banner | +| `OutputWriter` | Paths, `__init__.py`, clean-up | Path layout from the same helper the JSON generator uses, so folders match the JSON repo. Only deletes folders it owns; never touches `_support/` | +| `Reporter` | Warnings list, counts, manifest file | Feeds tests and PR descriptions | + +Entry point `PythonSchemaGeneration` (console): arguments for output folder, organisation list, optional type filter +(for tests and quick local runs), and a switch to write the manifest. + +Shared C# logic is **referenced, not copied**: `BHoM_Engine` (`IsOmNamespace`, `IsOmAssembly`), `Reflection_Engine` +(`Subtypes`), `Quantities_oM` (quantity attributes) and `JsonSchema_Engine` (`RelativeSchemaId`, `IsInOrg`), with +`HintPath`s into `ProgramData\BHoM\Assemblies` like the existing projects. All of these go through one small internal +adapter file, so replacing a reference later is a one-file change. + +### 4.2 Shape of the generated Python (per type) + +- File per type: `bhom_schema///.py`, class names unchanged except where unavoidable + (`Output_1`). +- Concrete classes: Pydantic models inheriting from the support base class and from their interfaces. +- Interfaces: plain `ABC` marker classes. Abstract classes: model + `ABC`. Their `__bhom__` marker lists the generated classes that implement them + (`implementers`: C# name to module path), which are the `_t` values a field of that type accepts. +- Fields typed with an interface or abstract class are written `Polymorphic[X]`; base classes stay plain. +- Enums: `BHoMEnum` subclasses (`BHoMFlags` for [Flags] enums), written and read by member name; `None` member emitted as `None_` with the real name kept + in the metadata. Delegate fields are left out of the JSON Schema (`SkipJsonSchema`). +- Fields: PascalCase exactly as in C#; required unless a default was found; `Field(default_factory=…)` for mutable + defaults; typed `Annotated[...]` with `ClrType`, `Quantity`, `DisplayText` markers where applicable. +- Docstrings and `Field(description=…)` from `[Description]`. +- Class-level `__bhom__` marker: C# full name, namespace, assembly, **repo**, plus generic and obsolete info. +- Cross-module references: bottom-of-module import plus `model_rebuild()` (gotcha 9); `from __future__ import annotations`. +- Sets/dictionaries keyed or filled by BHoM objects fall back to lists (gotcha 11). + +### 4.3 Hand-written support package `bhom_schema/_support/` + +Small and stable, imported by every generated file: `SchemaModel` base class (shared `model_config`; writes `_t`, reads and checks it, and carries +`_bhomVersion`), `Polymorphic` (interface fields), `BHoMEnum` and `BHoMFlags` (enums by name), the `ClrType`, `Quantity` and `DisplayText` markers, and a +`Color` model. Written by hand and under test (`test_support/`), never overwritten by the generator. + +### 4.4 Python → C# (`.ci/generation/py2cs`) + +| Stage | Responsibility | +|---|---| +| `check` | Strict read of a package: collects every problem (file, line, message) and warns about missing descriptions; drafts nothing | +| `reader` | Parse a `.py` file with `ast` (no import side effects, works per file) into the same type manifest. Accepts the common hand-written styles (`Optional`, `List`, positional `Field` default), works out a missing `__bhom__` marker, and collects problems instead of stopping at the first | +| `diff` | Compare the manifest at the PR base and head: added, removed, modified types and members. Also lists the **breaking** changes (property removed or renamed, type changed, enum member removed or renumbered, class or base removed) | +| `align` | Alignment check: runs the generator over the built merged C# for the changed classes and compares the result with the PR (`compare.py`). A Python PR may only be merged when it passes | +| `resolve` | `__bhom__["repo"]` → repository, validated against the GitHub API; falls back to scanning the repos in `Included-repositories.txt` for a folder named after the assembly; compares names case-insensitively | +| `locate` | Finds the existing `.cs` file inside a checked-out C# repo by searching for the declaration (`class/interface/enum/struct `), file name first. **Never derives the path from the namespace** (43% of files don't match, gotcha 14) | +| `place` | Chooses a path for a **new** type: folder of sibling types in the same namespace, else `Enums/` convention, else namespace folder, else assembly root; the choice is stated in the PR | +| `emitter` | New type → whole new file (BHoM copyright header, namespace, usings, `[Description]`, properties, enum members). Modified type → **member-level patch**: py2cs writes a change list and the Roslyn patcher (`PythonSchemaGeneration.Patcher`) changes only those members, keeping everything else in the file (audit 1, step 2) | +| `report` | Structured change report per type (added/removed/changed members) for the PR body, with a "Breaking changes" section and the `breaking-change` label (`plan.json` carries `labels`) | +| `pr` | Creates or updates the draft C# PR and the links back (section 8); real and mock GitHub clients | +| `__main__` | `python -m py2cs check / manifest / diff / align / plan / drafts / resolve` (`pr` comes with phase 8) | + +The reader does static parsing instead of importing so it can run on exactly the files a PR changed, on the base +revision without installing it, and without executing any code. + +Decision recorded (revised 2026-10-26, audit 1): for a modified C# type the converter **patches the existing file member by +member** with Roslyn, so only the changed members differ, in a **draft** PR, plus the change report. A human reviews and merges; +nothing is auto-merged. The earlier full-file replacement lost attributes such as `[DocumentationURL]` and reformatted +unrelated code. + +### 4.5 Tooling and conventions + +Python 3.12, Pydantic `>=2.13,<3`, pytest, Pyright pinned (strict) in CI with Pylance in the editor. C# targets net8.0 like +the current generator (net8 SDK and runtime are installed here). Tests use NUnit, as in the JSON repo. The BHoM copyright +header and the repo's comment style apply to C# files; comment length stays in line with existing files. + +--- + +## 5. Phases + +Sizes are relative: S = hours, M = a day or two, L = several days. Every phase ends with its own build and test run. + +### Phase 0: Clean up and scaffold (S) + +- Remove the template files listed in section 2; rewrite `README.md`; trim `dependencies.txt`; extend `.gitignore`; + copy `Included-repositories.txt`, `CODEOWNERS`, `build.ps1`. +- Add `pyproject.toml`, `pyrightconfig.json`, empty `src/bhom_schema/` with `_support/`, and the folder skeleton. +- Create `PythonSchemaGeneration.sln` with the exe, core, test and fixture projects (empty but building), referencing the + BHoM assemblies via `HintPath`. +- **Assembly preflight:** `run-all.ps1` first checks that every assembly the spike set needs is present in + `ProgramData\BHoM\Assemblies` (`BHoM`, `Geometry_oM`, `Structure_oM`, `Graphics_oM`, `Acoustic_oM`, `Architecture_oM`, + `Quantities_oM`, `dKoP_oM`, and the engine dlls the generator references). `dKoP_oM.dll` is already installed on this + machine (built 2026-10-07), so nothing needs building now. The preflight **reports** what is missing and never builds a + repo itself; **build a repo only when its dll is actually missing**. In CI the real-type tests for the spike set must + **fail**, not skip, when an assembly is missing; locally they skip with a clear message. +- A first `run-all.ps1` that builds and runs the empty test projects, `pytest` and `pyright`. +- **Done when:** `dotnet build` of the solution succeeds; `pytest` and `pyright` run (zero tests, zero errors). + +### Phase 1: Executable specification for the spike set (M) + +Hand-write the expected Python for the spike set as **golden files**, plus the support package. This turns the design into +something Pyright and pytest can already check. + +- `ProjectIdentification` (and its interface `IdKoPObject`), `Line` (+ `Point`), `ICurve` chain, `BHoMObject`, `Bar`, + `GradientCenteringOptions`, ``BHoMGroup`1``, the two `Room`s, `Gradient`, `ComparisonConfig`. +- Tests: all golden files import; `model_rebuild()` succeeds; Pyright strict clean; behaviour tests per gotcha (MRO order, + `None_` enum, bounded generic, interface acceptance and rejection, same-name classes importable together, mutable defaults + not shared, `__bhom__` present). +- `ProjectIdentification`-specific: four string fields default to `""` (constructing with no arguments works); it is an + instance of `IdKoPObject` and **not** of `BHoMObject`; an `IdKoPObject`-typed field accepts it and rejects an unrelated + object; `__bhom__["repo"] == "BHoM/dKoP_Toolkit"`, assembly `dKoP_oM`, namespace `BH.oM.dKoP`; file lives at + `dKoP_oM/ProjectIdentification.py` (not under `AdministrativeInformation/`). +- Add the hand-written cyclic fixture pair for the cycle pattern. +- **Done when:** the golden set passes pytest and Pyright. Design flaws found here are cheap to fix. + +### Phase 2: Type model and extraction (M) + +- Build `Fixtures.Alpha_oM` and `Fixtures.Beta_oM`: small fake oM assemblies that mimic every spike case (enum with `None`, + generic with constraint, interface chain with two parents, defaulted base plus required subclass, same-named types in the + two assemblies, a set and dictionary of objects, `DateTime`, `Color`, quantity and description attributes). Specific + `ProjectIdentification` twins: an interface **without** an `I` prefix, a flat class of defaulted `string` properties + implementing it, source files placed in a folder that deliberately differs from the namespace, and an assembly + `Description` pointing at a different repo URL than the other fixture assembly. +- Implement `AssemblyLoader`, `TypeSelector`, `TypeExtractor`, `ProvenanceReader`, the manifest writer. +- NUnit tests: extraction result per fixture type equals an expected manifest; selection rules; base reduction and + ordering; default discovery (string defaults); generic handling; interface detected without name heuristics; repo + normalisation. +- Tier-2 tests (installed or CI-built BHoM assemblies): the real spike types extract with the expected bases, properties + and defaults; for `ProjectIdentification`: four declared properties, defaults `""`, base chain `IdKoPObject → IObject`, + repo `BHoM/dKoP_Toolkit`. +- **Done when:** manifests for fixtures and real spike types match expectations. + +### Phase 3: Mapping, emission and writing (L) + +- Implement `TypeMapper`, `NameSanitiser`, `PythonEmitter`, `OutputWriter`, `Reporter`. +- Golden-file tests: emitted text for each fixture type equals the committed expected output (and the phase 1 golden files + for the real-type equivalents, including `ProjectIdentification`). +- Path test: `BH.oM.dKoP.ProjectIdentification` is written to `dKoP_oM/ProjectIdentification.py` using the same rule as the + JSON repo (assembly folder, namespace minus its first segment). +- Determinism test: two runs are byte-identical. Clean-up test: a type removed from the input removes only its file and + empty folders, never `_support/`. Warning test: unsupported types become `Any` with a warning. +- Run the generator over the real spike types into a scratch folder and push it through the phase 1 pytest and Pyright + checks. +- **Done when:** generated output for fixtures and real spike types passes pytest and Pyright strict. + +### Phase 4: Metadata and provenance (M) + +- Descriptions, quantity markers, display text, dynamic-property marker, CLR type annotations, full `__bhom__` content. +- Tests: `__bhom__` complete for every generated class (CLR name, namespace, assembly, repo); quantity unit and category on + the right properties (for example `Bar.OrientationAngle`); descriptions appear as docstrings and field descriptions; CLR + types preserved (`long` stays recoverable); `ProjectIdentification` carries no description and that does not break + emission (no empty docstring noise). +- **Done when:** the metadata assertions pass on fixtures and real types. + +### Phase 5: Python → C# reader, diff, locate, place and emitter (L) + +- Implement `py2cs` reader, `diff`, `resolve`, `locate`, `place`, `emitter`, `report`. +- Unit tests with before/after Python fixture pairs: new type, new/removed/changed property, renamed enum member, new + interface, removed type, and a change to `ProjectIdentification` (add `ProjectNumber`). Expected diff, C# and report are + committed golden files. +- `locate` tests on a recorded mini C# tree: `ProjectIdentification.cs` is found under `AdministrativeInformation/` although + the namespace is `BH.oM.dKoP`; `Line.cs` is found under `Curve/`; a missing type gives a clear "not found, treat as new" + result; two files with the same name resolve by declaration. +- `place` tests: sibling-folder rule, `Enums/` rule, namespace-folder rule, assembly-root fallback. +- `emitter` tests: whole-file output for new types; suggested replacement for a modified type keeps the existing header, + usings and comments. +- `resolve` tests against recorded repo listings: `dKoP_oM → BHoM/dKoP_Toolkit`, `Geometry_oM → BHoM/BHoM`, + `CodeCompliance_oM → BHoM/Test_Toolkit`, case differences (`dKop_Toolkit`), a missing `repo` (scan fallback), an unknown + assembly (clear error, no guess). +- **Done when:** the golden diff, locate, place and emission tests pass and the CLI works end to end on fixtures. + +### Phase 6: Alignment round trip (M) + +For each spike type: generated Python → `py2cs` draft C# → compile with Roslyn inside an NUnit test → reflect → compare the +resulting manifest with the original (loose comparison per research doc section 8: names, namespaces, assemblies, bases, +interfaces, property names and types, enum members, carried attributes). +- `run-all.ps1` orchestrates: generate Python, run `py2cs`, then `dotnet test` with the draft folder passed in. +- `ProjectIdentification` round trip: the draft compiles, keeps `: IdKoPObject`, four `virtual string` properties with `= ""` + defaults, namespace `BH.oM.dKoP`. +- **Done when:** all spike round trips compile and match within the agreed tolerance; differences are listed and + intentional. + +### Phase 7: Full-corpus run and hardening (M) + +- Run the generator over every available oM assembly. Import every module, rebuild every model, Pyright strict over the whole + package; record import time and Pyright time. +- Re-measure the cycle count from the DLLs (including abstract classes); decide on lazy loading if import time or memory is + poor. +- Fix mapping gaps surfaced by the warnings list; confirm counts: manifest, files written and modules imported are equal. +- **Done when:** the whole corpus imports and type-checks, the warnings list is reviewed, timings are recorded. + +### Phase 8: CI wiring (L) + +Both parts are in scope. + +- **8a `python-schema-generation.yml`** (the diagram's "Create/Update schema"): weekly and manual. Same recipe as the JSON + repo's weekly job (clone the repos in `Included-repositories.txt`, build in dependency order with `build.ps1`, build and + run the generator and the checks), then commit the result straight to `develop`. The C# is the source of truth, so the weekly + run just rewrites the Python from it; it is not a pull request. If a check fails, nothing is committed. +- **8b `pr-checks.yml`:** on every PR run `dotnet build`, NUnit, pytest and Pyright. +- **8c `linked-csharp-pr.yml`:** the two linked PRs. Full design and wiring in section 8. +- **8d Rehearsal on `BHoM/CSharpSchema_Test`**, a repository made for this, so no BHoM repository is written to. The test subject is a new + property on `Beam` (and a new class) in `src/bhom_schema/FixtureAlpha_oM`: + 1. **Local dry run.** In a scratch copy of `src/bhom_schema`, add the property, then run `py2cs check`, `repos`, `plan` and + `pr --dry-run` against the clone `C:\Github\CSharpSchema_Test`. The dry run **never writes into that clone** and, without a token, + never calls GitHub: it prints the files, the branch, the PR title and the body. (Done once: `Objects.cs` gets the three lines + of the property, and `Panel.cs` is new.) + 2. **Workflow dry run.** Open the Python PR and run `linked-csharp-pr.yml` with `dry_run=true`: it makes the App token for + `CSharpSchema_Test` only (proving the installation and permissions work) and prints what it would do. + 3. **Live run (needs your explicit go-ahead at that moment).** Re-run with `dry_run=false`. It pushes branch + `python-schema/pr-` to `BHoM/CSharpSchema_Test` and opens a **draft** PR. Check the cross-links, run it again to confirm it + updates the same branch and PR, then **close the Python PR without merging**: the workflow closes the C# draft with a comment. + Delete the branch by hand. +- **Done when:** the three workflows run green on a branch of this repo, the dry runs match the expected golden output, and the + rehearsal produced a correctly linked draft PR in `BHoM/CSharpSchema_Test` that has then been cleaned up. + +### Final step (every phase, and last): full build and test + +`run-all.ps1` runs, in order: `dotnet build` of the solution (Release), `dotnet test`, generator over fixtures, `pytest`, +`pyright`, `py2cs` over fixtures, the round-trip tests and the PR-raiser dry run; then, where the BHoM assemblies are +installed, the real-type tests and the full-corpus run. A phase is only finished when this script passes. + +--- + +## 6. Test matrix (mapped to the workflow in the diagram) + +| Workflow step / requirement | Test | Layer | +|---|---|---| +| C# → Python produces valid Python | Every generated module imports, every model rebuilds, Pyright strict clean | pytest + Pyright | +| Output is stable, so scheduled runs create small diffs | Two runs byte-identical; output sorted; fixed header | NUnit | +| "Create/Update schema" PR contains only real changes | Remove or rename a fixture type: only its file changes or disappears; `_support/` untouched | NUnit | +| Same types as the JSON export | Selector gives the same type set as the JSON generator rules for the fixtures; folder paths use the same helper (`ProjectIdentification` → `dKoP_oM/ProjectIdentification.py`) | NUnit | +| Independent of the weekly JSON job | Generator has no read or write dependency on `BHoM_JSONSchema` (project and path guard test) | NUnit | +| Re-use of schema by Python projects | Public import paths match the layout; importing a type does not import unrelated assemblies | pytest | +| PR → Python → C# converter | Before/after fixture pairs give the exact expected diff, C# draft and report | pytest | +| Existing C# file found although folders ≠ namespaces | `locate` finds `ProjectIdentification.cs` in `AdministrativeInformation/` and `Line.cs` in `Curve/` | pytest | +| New C# file placed sensibly | `place` rules (siblings, `Enums/`, namespace, root) | pytest | +| Modified type → suggested full-file replacement | Replacement keeps existing header/usings/comments; PR is a draft; body carries the report | pytest | +| Alignment of Python and C# changes | Round trip of the spike set compiles and matches the original manifest | NUnit + pytest (orchestrated) | +| Locating the C# repository for the linked PR | `__bhom__["repo"]` used first; scan fallback; case-insensitive; unknown assembly gives a clear error | pytest | +| Class carries what the Action needs | `__bhom__` has CLR name, namespace, assembly and repo on every generated class | pytest | +| The weekly generation never raises C# PRs | It commits directly and `linked-csharp-pr.yml` only triggers on `pull_request`, never on `push` | workflow review + dry run | +| Idempotent re-run | Pushing again to the same Python PR updates the same C# branch and PR, no duplicates | pytest with mock GitHub client | +| Closing the Python PR | Closed unmerged → linked C# PRs closed with a comment; merged → comment only | pytest with mock GitHub client | +| App token is least-privilege | One token per target repository, minted for that repository only; token never printed | workflow review + dry run | +| Fork PRs | Workflow does not run with the App secrets on fork PRs | workflow condition check | +| The spike set | Phase 1 golden, phase 2 extraction, phase 3 emission, phase 4 metadata, phase 5 locate/diff, phase 6 round trips | all layers | +| Scale | Full-corpus import, rebuild, Pyright, timings, count equality | CI / local with BHoM installed | + +Two input tiers keep tests reliable: **fixtures** (tiny fake assemblies built in the repo; need only `BHoM.dll`) run anywhere, +and **real-type tests** (the actual oM) run when the BHoM assemblies are installed or built in CI. + +### Where `ProjectIdentification` is checked + +| Phase | Check | +|---|---| +| 1 | Golden file, defaults, interface-only base, `__bhom__`, path | +| 2 | Fixture twin and real-type extraction (interface without `I`, defaults `""`, repo) | +| 3 | Emission golden and output path | +| 4 | Provenance (`repo`, assembly, namespace); no description handled cleanly | +| 5 | `locate` (folder ≠ namespace), `resolve` (toolkit repo), diff/emit for an added property | +| 6 | Round trip compiles and matches | +| 7 | Part of the full-corpus import and type-check (needs `dKoP_oM.dll`, already installed) | +| 8 | Subject of the rehearsal: a new property on `FixtureAlpha_oM/Beam` in Python, dry-run against `C:\Github\CSharpSchema_Test`, then a live draft PR in `BHoM/CSharpSchema_Test` | + +--- + +## 7. Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Hidden dependencies on a locally installed BHoM make results differ on CI | Fixtures for logic tests; real-type tests fail in CI and skip locally with a message; CI builds the BHoM repos itself, as the JSON job does | +| A needed assembly is missing from `ProgramData\BHoM\Assemblies` (`dKoP_oM.dll` is present now) | Phase 0 preflight reports it; build that repo only if its dll is really missing; fixture twins cover the logic meanwhile | +| The live rehearsal leaves a stray PR or branch in `BHoM/CSharpSchema_Test`, or the test property gets merged | Draft PR clearly labelled as a rehearsal, explicit go-ahead before the live step, mandatory clean-up step, Python PR is closed unmerged | +| `IsInOrg` and other helpers come from JSONSchema_Toolkit, which must be built before our generator in CI | `JSONSchema_Toolkit` is already in `Included-repositories.txt`; one adapter file isolates the reference | +| Reflection can't see default values (gotcha 3) | Instantiate-and-read where possible; required otherwise; golden tests pin the behaviour | +| Cycle or import-time problems only appear on the full corpus | Phase 1 cycle fixture, phase 7 full-corpus gate, lazy loading as the fallback | +| Pyright strict is noisy on generated code | Emit typed default factories; agree a short, explicit list of rule exceptions in `pyrightconfig.json` | +| Round-trip comparison is too strict or too loose | Define the loose comparison once and keep it in one test helper; review differences in phase 6 | +| Suggested replacement files create noisy C# diffs | Reuse the existing header, usings and comments; always a draft PR; report in the PR body | +| New-file placement guesses wrong | State the rule used in the PR; reviewer moves the file | +| Weekly generation commit triggers C# PRs | It is a push, not a PR, and the linked-PR workflow has no `push` trigger | +| `develop` is protected, so the weekly job cannot push to it | Allow the GitHub App (or a deploy key) to bypass the pull request rule for this one commit; the generation checks run before the commit | +| App token over-privileged or leaked | One token per target repo, short-lived, `permissions` minimised, masked in logs, no fork access | +| Target repo not covered by the App installation | `resolve` checks reachability first and comments on the Python PR with the missing repo instead of failing silently | + +--- + +## 8. Linked C# PR workflow and GitHub App wiring + +You already have a GitHub App with the needed access, so no new credential type is required. This is how to connect it to +this repository. + +### 8.1 One-time setup (done by someone with org owner/admin rights) + +1. **Confirm the App's permissions** (App settings → *Permissions & events*): *Contents: read and write* (push a branch to a + C# repo), *Pull requests: read and write* (open, update and close the C# PR and comment), *Metadata: read* (automatic). + Nothing else is needed. Add *Workflows* only if a C# PR would ever change `.github/workflows` (it won't). +2. **Confirm the installation covers every target repo.** App settings → *Install App* → the organisation → *Repository access*. + It must include `BHoM_PythonSchema` (so the workflow can use its secrets) and every repository that owns oM assemblies + (the repos in `Included-repositories.txt`). A repo outside the installation cannot get a token. +3. **Store the credentials.** Note the App ID (or Client ID) and generate a private key (`.pem`). Add them where the workflow can + read them: either an organisation secret/variable shared with `BHoM_PythonSchema`, or repository-level ones. Suggested + names (reuse the names your other workflows already use if the secret exists): + - variable `BHOM_APP_ID` + - secret `BHOM_APP_PRIVATE_KEY` (the full PEM text including the BEGIN/END lines) +4. **Protect the secrets.** Optionally attach them to a GitHub **Environment** (for example `linked-csharp-pr`) so only the + linked-PR job can read them, with a required reviewer if you want a human gate before anything is raised. +5. **The rehearsal repository is `BHoM/CSharpSchema_Test`** (phase 8d), so that repo must be within the installation (step 2). The + installation should cover only `BHoM_PythonSchema` and `CSharpSchema_Test` for now, so the App cannot write to any other repository. + +### 8.2 How the workflow uses it + +`linked-csharp-pr.yml`: +- **Triggers:** `pull_request` (opened, synchronize, reopened, ready_for_review, closed) with a path filter on + `src/bhom_schema/**`, plus `workflow_dispatch` with `pr_number` and `dry_run` inputs. `concurrency` is per PR number so a + newer push cancels an older run. +- **Guard (job `if`):** head repository is this repo (not a fork, because forks never receive secrets). There is no `push` + trigger, so the weekly generation commit never starts this workflow. +- **Job `plan`:** check out with full history, set up Python, `pip install`, run `py2cs diff` between the PR base and head, and + `py2cs resolve` to list the affected repositories. Exit cleanly if there are no changes. Output the repo list as a matrix. +- **Job `raise` (one matrix leg per affected repo):** + 1. mint a token scoped to just that repo: + `actions/create-github-app-token` (pin a major version or commit SHA; check the current release when implementing) with + `app-id`, `private-key`, `owner: BHoM` and `repositories: `; the token lasts about an hour and is revoked + when the job ends; + 2. check out that C# repo's `develop` branch with the token; + 3. `py2cs locate` + `py2cs emit` write the new files and suggested replacements into the checkout; + 4. commit on branch `python-schema/pr-` as the App's bot identity and push; + 5. `py2cs pr` creates or updates a **draft** PR to `develop` whose body follows the C# repo's PR template (with + "Depends on BHoM/BHoM_PythonSchema#N"), embeds the change report, and states the file-placement choices; + 6. comment on the Python PR with the link (using the workflow's own `GITHUB_TOKEN`: `pull-requests: write`, `contents: read`). +- **On `closed`:** unmerged → close the linked C# PRs with a comment; merged → comment on them that the Python PR merged. +- **`dry_run`:** does everything except pushing and creating PRs, and prints what it would do; used for the rehearsal and for + debugging. It still mints the real App token, so a dry run proves the installation and permissions. Run locally, the same + mode takes `--repo-root ` and works on a temporary copy, so a developer's clone is never modified. +- **Safety details:** the token is only passed to steps that need it, via environment variables, never echoed; the default + `GITHUB_TOKEN` permissions are set to read-only at workflow level and raised per job. + +### 8.3 In code + +`py2cs.pr` talks to GitHub only through a small `GitHubClient` interface (get repo, list tree, create or update branch and +file, create or update PR, comment, close). The real client wraps the REST API directly (the `gh` CLI is not installed on +this machine, so local dry runs must not depend on it; GitHub-hosted runners have it, but we don't rely on it). Tests use a +mock client with recorded responses, so branch naming, idempotence and the close behaviour are unit-tested +without network access. + +### 8.4 What I need from you before phase 8c + +1. The App's name/slug and its **App ID or Client ID**, and the names of the secret and variable that already hold them (so + the workflow reuses them rather than creating duplicates). +2. Confirmation that the installation covers `BHoM_PythonSchema` and **all repos in `Included-repositories.txt`**. +3. Your go-ahead, at the time, for the **live rehearsal** that opens a draft PR in `BHoM/CSharpSchema_Test` (the dry runs need no + approval). +4. Whether the linked PR should run for every Python PR automatically (current plan) or only when a label such as + `csharp-sync` is present, or after approval of an Environment. +5. Whether the weekly generation job should also use the App token to clone any private BHoM-organisation toolkits (public + repos don't need it). + +--- + +## 9. Decisions recorded + +| Question | Decision | +|---|---| +| Modified C# types | **Member-level patch** with Roslyn (revised by audit 1; was a full-file replacement): only changed members are touched, draft PR, change report in the PR body. | +| CI scope | **Both** the weekly generation / PR-check workflows **and** the linked-PR Action are in scope now. | +| Repository list, owners, workflow recipe | `Included-repositories.txt` (BHoM, BHoM_Engine, JSONSchema_Toolkit, CSharpSchema_Test) is cloned and built on GitHub by our own workflows, with the JSON repo's `build.ps1`. BHoM_JSONSchema is not changed for now, and the two DLL sets may stay separate for good. `Converted-repositories.txt` lists what is converted and where C# PRs may be raised: CSharpSchema_Test only. | +| Spike set | `Point` replaced by `ProjectIdentification` as the "simple flat class" slot; `Point` is still generated and checked as `Line`'s dependency. | +| Cross-repo credential | Existing **GitHub App**, one short-lived token per target repository. | +| `dKoP_oM.dll` | Already installed locally; preflight reports missing dlls and builds a repo only if its dll is missing. | +| Rehearsal target | **`BHoM/CSharpSchema_Test`** (local clone `C:\Github\CSharpSchema_Test` for dry runs); no BHoM repository is written to. Live step needs explicit go-ahead and is cleaned up afterwards. | +| Repo discovery | `__bhom__["repo"]` from the assembly's `Description` first; folder scan only as fallback. | +| Existing C# file lookup | Search by declaration; never by namespace path. | diff --git a/docs/CSharp-to-Python-Gotchas.md b/docs/CSharp-to-Python-Gotchas.md new file mode 100644 index 0000000..81ad93d --- /dev/null +++ b/docs/CSharp-to-Python-Gotchas.md @@ -0,0 +1,693 @@ +# C# → Python class conversion: technical research + +Purpose: record what we know, what we measured, and what is still open, so a coding plan can be written +from it. Anything marked **[verified]** was +checked by an experiment or by reading the real repos on 2026-10-05 (Python 3.12.10, Pydantic 2.13.5). +Anything marked **[unverified]** is a proposal or assumption that the first spike must confirm. + +--- + +## 1. Scope and decisions already made + +| Topic | Decision | +|---|---| +| What the Python package is | Python **class definitions** generated from the C# oM. Nothing else. | +| JSON | **Out of scope.** The Python classes never save or load JSON, and never exchange BHoM-format JSON with C# or `BHoM_JSONSchema`. | +| Base technology | Pydantic v2 (`BaseModel`). | +| Python version | **3.12** minimum (allows `class Group[T: BHoMObject]` generic syntax; verified with Pydantic 2.13.5). | +| Package layout | One C# type per Python file, inside a single wrapper package `bhom_schema` (section 6). | +| Naming | C# PascalCase property names kept exactly as in C#. | +| Metadata | Carry as much C# metadata as possible onto each Python class (section 7). | +| Tooling | Type hints everywhere, checked by Pyright in CI, Pylance in the editor (section 9). | +| C# → Python converter | The important one. It must emit **valid, importable Python**. Lives in this repo under `.ci/generation`. | +| Python → C# converter | Used **only** in the linked-PR flow, to draft C# that is *aligned* with a Python change. It does **not** need to reproduce C# exactly. A human reviews the C# PR. | +| Reuse of existing code | The converter lives here but **references the existing assemblies** (JSONSchema_Toolkit / BHoM engine) for shared reflection helpers. | +| Source of truth | The C# oM (diagram: "C# oM from `_Toolkit` and `Python_Toolkit`"). Python is derived from it. | +| Automation | A GitHub Action with org-wide API access raises the linked C# PR, so every Python class must carry enough information to locate its C# home. | + +### Pipeline (from the system design diagram) + +``` +C# oM (source of truth) ──► C# to Python converter ──► BHoM_PythonSchema (this repo) + │ + └──► C# to JSON converter (weekly) ──► BHoM_JSONSchema (independent, unchanged) + +Python change in this repo ─► PR ─► Python to C# converter ─► PR in the C# repo (two linked PRs) +``` + +Because JSON is out of scope, the two output repos only share one thing: the C# oM they both come from. + +--- + +## 2. How the converter will read C# (and why that matters) + +The existing generator, [Generate.cs](C:\Github\BHoM_JSONSchema\.ci\Generation\SchemaGeneration\SchemaGeneration\Generate.cs) +and [ToJsonSchema.cs](C:\Github\JSONSchema_Toolkit\JsonSchema_Engine\Convert\ToJsonSchema.cs), does not parse +`.cs` source. It loads the **compiled** `*oM.dll` files from the BHoM folder and uses .NET **reflection** +("ask the compiled code what classes, properties and types it contains"). It already: + +- filters to oM types (`IsOmNamespace`), skipping static classes and anything not an enum or `IObject`; +- filters assemblies by organisation (`IsInOrg`); +- finds interface implementers (`Subtypes`); +- reads descriptions and quantity attributes; +- unwraps nullables, arrays, dictionaries, tuples and generic parameters. + +We reuse that walk and replace the output side (JSON schema objects) with a Python code emitter. + +**Consequence of using reflection:** it only sees what survives compilation. Comments, the order things were +written in, and default-value expressions are not visible (see gotcha 3). + +**Language choice:** the C# → Python converter is written in **C#** (reflection is a .NET feature and the +helpers already exist). The Python → C# converter is written in **Python** (it inspects the generated +Pydantic classes). This means the repo has a `dotnet` side and a `pytest` side. + +--- + +## 3. What the real oM looks like (measured) + +Counted from the 2,007 schema files in `BHoM_JSONSchema` as a proxy for the oM. These are approximate +(classification is by schema shape); the converter will recount from the DLLs. + +| Fact | Count / finding | Why it matters | +|---|---|---| +| Total types | 2,007 | Import time and file count (gotcha 10) | +| Enums | 317 | Straightforward `Enum` classes | +| Interfaces + abstract classes | 227 | Need a representation (gotchas 6 and 9) | +| Concrete classes/structs | ~1,463 | The bulk | +| Assemblies (top-level folders) | ~70 | Package layout, provenance | +| Folder depth below an assembly | 0–3 levels of namespace | Package nesting | +| Generic types (name has a backtick, e.g. ``Output`10``) | 34 | Names are not valid Python (gotcha 5) | +| Enum members named `None` | 12 | Python keyword (gotcha 7) | +| Property names that are Python keywords | **0** | Keyword problem is limited to enums | +| Property names that clash with Pydantic's own attributes | **0** | Pydantic risk is low | +| Properties named like builtins | 3 (`type` ×2, `Warning`) | Minor (gotcha 7) | +| Type names that are the **same** in different assemblies/namespaces | 64 (e.g. `Panel` ×4, `ISurface` ×3) | Big: names are **not** unique (gotcha 8) | +| Type names equal to a Python builtin or `typing` name | 0 | Good | +| Type names equal (ignoring case) to a stdlib module | 5 (`Time`, `Select`, `Profile`, `Encodings`, `UnitTest`) | Low risk if everything is inside our own package | +| Reference cycles if interfaces list their implementers (the JSON-schema way) | 15 groups, largest has 25 types (e.g. `IGeometry` ↔ `CompositeGeometry`) | These **disappear** with the ABC interface design (gotcha 6) | +| Reference cycles between concrete classes + interfaces, interfaces treated as dead ends | **0** | Circular imports look much smaller than feared (gotcha 9). Abstract classes could not be measured from schemas; re-measure from the DLLs | +| `HashSet` properties where `T` is a BHoM object | 6 properties in 3 types (`ComparisonConfig`, `RevitComparisonConfig`, ...). The other ~750 `HashSet`s are `HashSet` (`Tags`) | Hashability issue is narrow (gotcha 11) | +| Dictionaries with non-string keys (stored as `k`/`v` arrays) | 11 types, keys include `Frequency` (a class), `Guid`, `decimal` | Same | +| Properties documented with a quantity (length, stress, ...) | 348 types | Metadata worth carrying (section 7) | +| oM `.cs` files whose folder differs from the namespace-derived folder | 625 of 1,460 (43%) in the local clones | Never derive a C# path from a namespace (gotcha 14) | +| oM projects whose `AssemblyDescription` is the repo URL | 41 of 41 local projects | Repo can be recorded at generation time (gotcha 12) | + +--- + +## 4. Gotchas + +Each item: what C# does, what Python does, why it matters here, the plan, and the evidence. + +### 1. The type systems don't match + +C# has `int`, `long`, `short`, `byte`, `float`, `double`, `decimal`, `char`, and many collection types +(`T[]`, `List`, `IList`, `IEnumerable`, `HashSet`). Python has one `int`, one `float`, and mostly +`list`, `set`, `tuple`, `dict`. + +Why it matters: after conversion, `int` and `long` are both `int`, and `T[]`, `List`, `IList` are all +`list[T]`. The Python→C# PR draft would not know which one to write. + +Plan: one mapping table (section 5) is the only place types are translated. Record the original C# type on +each field with `typing.Annotated`, so the Python→C# converter can read it back. Python does not enforce it. + +### 2. Nullability + +`int?` can be null; plain `int` cannot. Reference types can usually be null. Python needs `X | None`. +Follow the existing `type.IsNullable()` / `Nullable.GetUnderlyingType` logic so the rule matches the JSON +schema generator. Getting it wrong either rejects valid objects or accepts invalid ones. + +### 3. Default values are invisible to reflection + +C# source can say `public List Points { get; set; } = new List();`. After compilation that +expression is buried in constructor code. + +Plan: +- Classes with a parameterless constructor: create an instance and read each property to learn its default. + Mutable defaults (lists, dicts) become `Field(default_factory=...)`. +- "Immutable" classes (constructor arguments required): fields are required, no default. The existing JSON + generator treats them the same way (`RequiredProperties`, which uses the constructor with most parameters). + +Evidence **[verified]**: Pydantic copies mutable defaults per instance (two objects with `Items: list[int] = []` +do not share the list). Still emit `default_factory` for clarity. + +### 4. Inheritance and field order + +Plain Python `dataclasses` refuse a required field after a defaulted one, even across inheritance. Almost +every BHoM object inherits from `BHoMObject` (defaulted `Name`, `Tags`, ...) and adds required fields. + +Evidence **[verified]**: a plain dataclass subclass failed with `non-default argument 'l' follows default +argument`; the equivalent Pydantic subclass worked. This is a main reason for choosing Pydantic. + +### 5. Names that are not valid Python (generics and backticks) + +The compiled name of a generic is ``Output`1``, ``Output`2`` ... ``Output`10``. 34 such types exist. + +Evidence **[verified]**: a class can be *created* with a backtick name at runtime, but `class Output`1:` is a +syntax error, so it cannot appear in source or be imported by name. + +Plan **[unverified]**: emit `Output_1`, `Output_2`... as the Python names (arity suffix; two classes of the +same base name and different arity must not collide) and keep the real CLR name in the provenance marker +(gotcha 12). Python `Generic[T]` + `TypeVar` carry the generic parameters. + +Related, Python can't express: multiple constraints (`where T : IA, IB`), `new()`, `struct`, `class` +constraints. Plan: bind to the first constraint, note the rest in the docstring/metadata. Looser typing, +but still valid code. + +Evidence **[verified]**: `Group(BaseModel, Generic[T])` with `T = TypeVar(bound=BHoMObject)` works, and +`Group[Child]` rejects a plain `BHoMObject` element. + +### 6. Interfaces, and the MRO error + +C# lets a class list interfaces in any order, even redundantly (`class X : IFoo, IBar` where `IBar : IFoo`). +Python computes lookup order (the **MRO**) and rejects an inconsistent one. Reflection also returns a +**flattened** list of all interfaces, not what was declared. + +Evidence **[verified]**: `class M(IFoo, IBar)` where `IBar(IFoo)` raised `TypeError: Cannot create a +consistent method resolution order`; `class M(IBar, IFoo)` worked. + +Plan: from the flattened list, drop any interface already implied by another one in the list, then order +most-specific first (topological sort). + +Representation **[verified]**: model each C# interface (and abstract-only type) as a plain `abc.ABC` class, +and set `model_config = ConfigDict(arbitrary_types_allowed=True)` on models that have fields typed as an +interface. Then: +- a field typed `IGeometry` accepts any implementing instance and **keeps its real subclass** (a `Point` + stays a `Point`); +- it rejects a non-implementer (`Input should be an instance of IGeometry`). + +Because JSON is out of scope, this removes the need for giant discriminated unions of every implementer +(`IObject` has ~1,469). That is a large simplification. + +**In plain words.** In C#, a property can be declared as an *interface*, a promise like "anything that is +a geometry": + +```csharp +public interface IGeometry { } +public class Point : IGeometry { ... } +public class Line : ICurve { ... } // ICurve itself extends IGeometry + +public class Holder { public IGeometry Geo { get; set; } } // can hold a Point, a Line, ... +``` + +Python has no `interface` keyword. We need *something* that says "this class counts as an IGeometry", and +a way to make a field accept "any IGeometry". There were two ways to do it: + +| | Option A: list every implementer | Option B (chosen): abstract base class | +|---|---|---| +| Python type of the field | `Point \| Line \| Arc \| ... \| (1,469 classes)` | `IGeometry` | +| What `IGeometry` is | a huge union type | an empty class `class IGeometry(ABC): ...` | +| Needs to know all implementers? | Yes, so the interface file must import every implementer | No, implementers import the interface, never the reverse | +| Cost | Slow imports, circular imports | Almost free | + +Option B in code: + +```python +from abc import ABC + +class IGeometry(ABC): ... # the "interface": just a marker class +class ICurve(IGeometry): ... # interfaces can extend interfaces + +class Point(BaseModel, IGeometry): # "Point implements IGeometry" = Point inherits from it + X: float + ... + +class Holder(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) # see below + Geo: IGeometry # accepts anything that inherits IGeometry +``` + +What the two bullet points mean: +- **"Keeps its real subclass":** `Holder(Geo=Point(...)).Geo` is still a `Point` with `X`, `Y`, `Z`. + Pydantic does not convert it into a generic `IGeometry` or throw its fields away. (With some validation + designs it would; this one does not.) +- **"Rejects a non-implementer":** `Holder(Geo=3)` fails with `Input should be an instance of IGeometry`, + so a wrong object cannot slip in. + +`arbitrary_types_allowed=True` is needed because `IGeometry` is a plain Python class, not a Pydantic model. +By default Pydantic only knows how to validate its own models and built-in types; this setting tells it +"for classes you don't recognise, just check `isinstance`". That check is exactly what we want here. + +Limitation: it checks *what the object is*, not what is inside a dict. `Holder(Geo={"X": 1})` would be +rejected because Pydantic can't know which implementer to build. That is acceptable, because the classes +never load from JSON or dictionaries by design. + +Pyright also understands this: `Holder(Geo=3)` is flagged in the editor before the code runs (section 9). + +### 7. Reserved words and shadowed names + +- Enum members called `None`: `None = 0` is a syntax error. Evidence **[verified]**: the functional form + `Enum("E", {"None": 0})` works (`E["None"]`, and `getattr(E, "None")`) but `E.None` cannot be written in + source. The suffix form `None_ = 0` works and is what users would type. Only the 12 `None` members were + found, so a keyword check (`keyword.iskeyword`) plus `_` suffix is enough. Keep the original name in the + metadata. +- Enum members called `name` / `value`: **[verified]** they work on Python 3.12, so no special handling, + but still worth a lint check on older versions. +- Properties named `type` (2) and `Warning` (1): Pydantic accepts them **[verified]**. However, a field named + `type` shadows the builtin **inside the class body**, so a later annotation like `type[int]` in the same + class breaks (**[verified]**: `TypeError: string indices must be integers`). Plan: emit annotations that + can't be affected (e.g. `builtins.type[...]`) or rename on collision. +- Properties are PascalCase, so no clash with Pydantic's lowercase attributes was found (0 of 16,153). + +### 8. Type names are not unique + +64 simple names appear in more than one place (`Panel` in Acoustic, Environment, Facade, Structure; +`ISurface` in three assemblies; ...). + +Consequences: +- Package layout must include assembly and namespace; a flat module is impossible. +- Generated annotations and imports must never rely on a bare class name being unique. Where two + same-named classes are both needed in one file, import with an alias. +- A single global "name → class" lookup used to resolve forward references would be ambiguous (see gotcha 9). + +### 9. Circular references + +C# classes reference each other freely. Python modules run top-to-bottom, so `a.py` importing `b.py` which +imports `a.py` can fail. + +How common is it here? **[verified from the schemas]** If interfaces had to list all their implementers +there would be 15 groups of types that reference each other, the biggest with 25 types (for example +`IGeometry` lists `CompositeGeometry`, and `CompositeGeometry` has a list of `IGeometry`). With the ABC +design (gotcha 6) an interface file points at nothing, and the same analysis found **zero** cycles among +concrete classes. Caveat: abstract classes carry real properties in Python but show up as interfaces in the +schemas, so the true count must be re-measured from the DLLs. Keep the safe pattern below anyway, because a +single cycle appearing in a future C# change must not break the whole package. + +Evidence **[verified]** (two-class cycle `Panel`↔`Opening` in separate packages): +- Using `from __future__ import annotations` + `if TYPE_CHECKING:` imports **alone is fragile**. It happened + to work only when the calling code had both classes in scope; when user code imported just `Panel`, creating + one failed with `Panel is not fully defined; you should define Opening, then call Panel.model_rebuild()`. +- Putting the cross-import at the **bottom of each module** (after the class) and calling `Model.model_rebuild()` + there worked in both import orders. +- A central `model_rebuild(_types_namespace=...)` also works but needs a name→class dictionary, which clashes + with gotcha 8. + +Recommended **[unverified at scale]**: bottom-of-module runtime imports with an explicit `model_rebuild()` per +model, using import aliases to disambiguate duplicate names. The spike must test deep chains and larger +cycles, not just a pair. + +### 10. Scale and import time + +Evidence **[verified, synthetic]**: 2,000 generated models (8 fields each, cross-package references, +inheritance depth up to ~29) imported in about 3–4 seconds cold with Pydantic 2.13.5, one file per model. +This test used deferred references and did not exercise full rebuild of long reference chains, so the real +number needs the spike. Options if it is too slow: lazy loading via module `__getattr__`, or fewer, larger +modules. + +### 11. Types with no Python twin + +`DateTime`, `Guid`, `Color`, `Type`, delegates, `DataTable`, `Bitmap`, `object`, tuples, `IComparable`. +Start from the special cases already in `SystemSchemas.cs`. Unknown or unsupported types become `Any` and +are written to a warning list in the converter output, so each gap is visible and can be handled later. + +**Hashability: why `set[T]` and some dictionaries can't hold BHoM objects.** + +A Python `set`, and the *keys* of a `dict`, can only contain **hashable** values. "Hashable" means the object +can produce a fixed number (a hash) that Python uses to find it quickly and detect duplicates. Strings, +numbers, tuples and enums are hashable. A normal mutable object is not: if its contents can change, its hash +could change, and Python would lose track of it. + +Pydantic models are mutable, so by default they are **not hashable** **[verified]**: + +```python +class P(BaseModel): + X: float = 0 + +{P()} # TypeError: unhashable type: 'P' +{P(): 1} # TypeError: unhashable type: 'P' +``` + +C# has no such restriction: `HashSet` and `Dictionary` are fine there. If we +translated them literally to `set[Point]` or `dict[Frequency, float]`, the generated class would fail the +moment someone tried to put an object in. + +How much does this matter? **[verified from the schemas]** Very little: of ~750 `HashSet` properties, nearly +all are `HashSet` (the `Tags` property inherited from `BHoMObject`), which is fine as `set[str]`. +Only 6 properties are sets of BHoM objects (`ComparisonConfig` and `RevitComparisonConfig`), and 11 types +have dictionaries with non-string keys (for example `Acoustic_oM/Panel` keyed by `Frequency`, a class; +`Gradient` keyed by `decimal`, which is fine; `Graph` keyed by `Guid`, also fine. Only class keys are a problem). + +Rule for the converter: +- element/key type is a string, number, enum, `Guid`, date or tuple of those → `set[T]` / `dict[K, V]`; +- element/key type is a BHoM object → use `list[T]` for the set, and `list[tuple[K, V]]` for the dictionary, + and record the original C# type in metadata so the uniqueness/key meaning is not lost. + +(The alternative, making every model `frozen=True` so it is hashable, would stop users changing any field +after creation, so we don't do it.) + +### 12. Provenance and finding the right C# repo + +Every generated class carries a class-level, non-field marker **[verified: a `ClassVar` named `__bhom__` is +excluded from `model_fields`]**: + +```python +class ProjectIdentification(SchemaModel, IdKoPObject): + __bhom__: ClassVar[dict[str, str]] = { + "clr_name": "BH.oM.dKoP.ProjectIdentification", # real C# full name (backticks and all) + "namespace": "BH.oM.dKoP", + "assembly": "dKoP_oM", + "repo": "BHoM/dKoP_Toolkit", # from the assembly's Description attribute + } +``` + +**The repo is available straight from the compiled assembly [verified].** Every oM project sets +`https://github.com/BHoM/` in its `.csproj`, which the compiler stores as the +assembly's `AssemblyDescription` attribute (the existing `IsInOrg` helper already reads it). In the local clones this +holds for **all 41 oM projects**: `Geometry_oM`, `Structure_oM` and most others → `BHoM/BHoM`, `dKoP_oM` → +`BHoM/dKoP_Toolkit`, `Revit_oM` → `BHoM/Revit_Toolkit`, `CodeCompliance_oM` → `BHoM/Test_Toolkit`, `Adapter_oM` → +`BHoM/BHoM_Adapter`, `Python_oM` → `BHoM/Python_Toolkit`, and so on. The converter therefore records `repo` at +generation time. This replaces the earlier idea of scanning every repository to build an `assembly → repo` map, and it +also resolves the known exceptions (the `BHoM` assembly and `CodeCompliance_oM`) without special cases. + +The Action still **validates** the recorded repo (it exists and the App can reach it) and falls back to the folder +scan only if `repo` is missing, for example on a hand-written class. Compare repository names case-insensitively +(`Included-repositories.txt` says `dKop_Toolkit`, the real name is `dKoP_Toolkit`). + +### 13. The output is a snapshot + +The Python reflects one build of the C# oM. If a toolkit DLL is not present when the converter runs, its +classes silently do not exist, and references to them fall back to `Any`. The converter must print which +assemblies were loaded, which types were skipped and why, and every fallback to `Any`. + +### 14. C# folders do not follow namespaces (locating and placing `.cs` files) + +The generated Python path comes from the **namespace** (same rule as the JSON repo: drop `BH.oM.`, drop the first +segment, the rest become folders). The C# file path comes from wherever the developer put the file, and the two often +disagree. Example **[verified]** from the spike type `ProjectIdentification`: + +| | Path | +|---|---| +| C# file | `dKoP_oM/AdministrativeInformation/ProjectIdentification.cs` (namespace `BH.oM.dKoP`) | +| Generated Python | `bhom_schema/dKoP_oM/ProjectIdentification.py` | + +How common: in the local clones **625 of 1,460 oM files (43%)** sit in a folder that differs from the namespace-derived +folder. Often it is a harmless convention (enums in an `Enums/` subfolder, `Curve/` for types in `BH.oM.Geometry`), but +`dKoP_oM` (108 files), `LifeCycleAssessment_oM` (90), `Structure_oM` (65), `Environment_oM` (51) and `Geometry_oM` (44) +show it heavily. + +Consequences for the Python → C# side: +- **Never derive an existing C# file path from the namespace.** Find the file by searching the assembly folder of the + resolved repo for the declaration (`class|interface|enum|struct `), using the file name as a first guess. +- **New types:** a placement rule is needed. Proposed order: (1) the folder of existing sibling types in the same + namespace (using the most common one), (2) an `Enums/` subfolder for enums when siblings do that, (3) the + namespace-derived folder, (4) the assembly root. Always mention the choice in the PR so a reviewer can move the file. +- **Generics and nested types:** search for the declaration, don't trust the file name. +- Interface names don't always start with `I`: `IdKoPObject` is an **interface** (`ProjectIdentification : IdKoPObject`). + Never classify types by name; use reflection (`IsInterface`) on the C# side and the abstract-class marker on the + Python side. + +--- + +### 15. Findings from the full-corpus run (all 1,800 types) + +Measured against every installed BHoM-organisation oM assembly (about 70 assemblies), not a sample. These supersede the +estimates earlier in this document. + +| Measure | Result | +|---|---| +| Types generated | 1,800 of 1,800, in 2,033 files (the rest are `__init__.py`) | +| Generation time | about 20 s | +| Import every module | about 4 s | +| Pyright strict over the whole package | about 17 s, 0 errors after the fixes below | +| Python-level reference cycles among concrete classes | 0 (interfaces list their properties in the marker, so they are dead ends) | +| Warnings | 181: 104 types mapped to `Any`, 32 `System.Attribute` bases dropped, 16 defaults not writable, 14 default instances not creatable, 6 complex collection defaults, 4 generic types not instantiable, 2 enum defaults not members, 3 other | + +The first run found four generator bugs and three more appeared while re-running. All are mapping gaps, none is a fault in the C#: + +1. **A generic class naming itself in a default.** `Field(default_factory=dict[str, Tree_1[T]])` is evaluated while the class body + runs, so `Tree_1` does not exist yet: `NameError` on import. Use a lambda (`lambda: {}`), which is evaluated later. The + subscripted-type form `list[T]` also makes Pyright strict infer the wrong element type, so every collection default in a class + that mentions a type parameter is now a lambda. +2. **An enum default that is not a member.** C# `Sparse Level { get; set; }` defaults to `default(Sparse)`, which is 0 even when no + member has the value 0 (real case: `dKoP.Overview.DFMACategory`). A plain Python `Enum` rejects it at import. Only `[Flags]` + enums may carry an undefined value. The field becomes required, and so does any field whose default instance contains it + (the `dKoP` part types holding `AdministrativeInformation`), otherwise Python code such as `Overview()` would fail. +3. **Multi-dimensional arrays.** `double[,]` maps to `list[list[float]]`, but reading the default by enumeration returns every cell + in one flat sequence. Write rows (`lambda: [[0.0, 0.0], [0.0, 0.0]]`). The C# draft needs `new double[,] { { 0.0, 0.0 }, { 0.0, 0.0 } }`. +4. **PEP 695 bounds cannot mention another type parameter.** C# `where TOpening : IOpening` would become + `TOpening: IOpening_1[TEdge]`, which Pyright rejects ("TypeVar constraint type cannot be generic"). The other parameter is + replaced by `Any`: `TOpening: IOpening_1[Any]`. The full constraint stays in the `generic_params` marker. +5. **`ReadOnlyDictionary`** was missing from the mapper's dictionary list and fell through to the list-of-tuples fallback. + Drafting C# back also needs `new ReadOnlyDictionary(new Dictionary())`, because it has no parameterless constructor. +6. **Collection flavour is not recoverable from a base type argument.** Properties carry a `ClrType` marker, base type arguments + (`ILifeCycleAssessmentModuleData>`) do not, so the Python reads back as `Dictionary<…>`. The structural + comparison therefore treats `IList`, `ReadOnlyCollection`, `ISet`, `ReadOnlyDictionary` and similar as their default flavour in bases. +7. **Descriptions are not clean text.** C# XML descriptions can end in `\r\n\r`. Comparisons must normalise line endings and trailing blanks. + +### 16. The oM does not model a project hierarchy + +Checked across the whole generated corpus, for anyone looking for project, building, storey, slab classes to reuse: + +- There is no `Project`, `Site` or `Storey` container. `Spatial.SettingOut.Level` (name, elevation) and + `Environment.Elements.Building` (location, elevation, type) exist, but **no other class has a property of either type**. +- Links between levels are by name strings (`Environment.Panel.ConnectedSpaces`, `Space.Zones`, `dKoP.SettingOut.BuildingLevel`, + `dKoP.PartIdentification.BuildingReference`), not by objects. +- Real nesting exists only in generic containers (`BHoMGroup.Elements`, `Tree.Children`, `DomainTree`, `Graph`), + `ProjectLifeCycleAssessment.Elements: List`, `Physical.Floor/Wall/Roof.Openings`, `Panel.Openings` (several domains), + `Construction.Layers` and `MEP` equipment `Parts`. +- Several domains model the same thing separately (Physical, Environment, Structure, Facade, dKoP), with no shared base beyond + `BHoMObject` and the dimensional interfaces (`IElement0D/1D/2D/M`). + +A project tree therefore has to be composed on top of the oM, not read from it. + +### 17. Existing tools do not replace the converters (researched, tested where allowed) + +| Direction | Candidate | Finding | +|---|---|---| +| Python to C# | py2many 0.9 | Active, but its backends are C++, Rust, Julia, Kotlin, Lean, Nim, Mojo, D, Dart, Go, V, Zig, SMT. **No C#** (checked on the installed CLI). | +| Python to C# | pytocs | Last commit February 2023, Python 2 era, all output typed `object`. Annotated assignments are parsed but never used by the translator, so field types would not reach the C#. Source read; not built (the sandbox blocked building the clone). | +| C# to Python | cs2python | Last commit 2018, early stage. | +| Either, through JSON Schema | Pydantic `model_json_schema()` | Loses the base class, the `__bhom__` provenance, enum member names (only `[0, 1, 2]`), generic parameters, quantity markers and factory defaults. Tested. | +| Either, through JSON Schema | datamodel-code-generator on the `BHoM_JSONSchema` files | No base classes, no defaults, BHoM-JSON `_t` and `_bhomVersion` fields, and the enum duplicated with `System.Enum` wrapper models. Tested. | +| C# to Python | pythonnet stub generators | Produce `.pyi` stubs for calling .NET from Python, not Pydantic classes. | +| C# to TypeScript or JavaScript | Rosetta, Transpose | Wrong target language. | + +Not run, for lack of permission to build or download third-party code: NJsonSchema, quicktype and a pytocs build. Their verdicts rest +on source and documentation plus the schema tests above, which show the information is lost before they would run. + +### 18. Environment and workflow gotchas hit while building this + +- **A local run converts whatever is built on that machine.** `%ProgramData%\BHoM\Assemblies` holds every toolkit built there, at + whatever commit was built. There is no repository filter, so "convert `C:\Github\BHoM`" actually converts every installed + BHoM-organisation assembly (filter the manifest on `repo == "BHoM/BHoM"` to isolate it). The weekly CI job, built in a clean + environment, is the only reproducible source. +- **Golden files are time-dependent** while the generated header carries the year (`2015 - 2026`). They went stale when the year + changed. Regenerate with `UPDATE_GOLDEN=1` and review the diff. +- **Line endings.** The repository mixes LF and CRLF with `core.autocrlf=true`. Generated files are written with LF. Editing a CRLF + file with a Python text-mode script silently rewrites every line ending; use the editor tool or write bytes. +- **Windows PowerShell 5.1** does not support `&&` (and `mkdir -p`, `cp -r`). Documentation commands are written for PowerShell, + one command per line. +- **A clean-up bug can hide for a long time.** The empty-folder removal in `OutputWriter` checked the parent folder instead of the + sub-folder. Only the clean-up test caught it, which is why that test must stay. + +## 5. Proposed type mapping **[unverified: to confirm in the spike]** + +| C# | Python | Notes | +|---|---|---| +| `bool` | `bool` | | +| `byte/sbyte/short/ushort/int/uint/long/ulong` | `int` | CLR type kept in `Annotated` metadata | +| `float/double` | `float` | CLR type kept in metadata | +| `decimal` | `decimal.Decimal` | No JSON wrapper needed (JSON is out of scope) | +| `char`, `string` | `str` | | +| `DateTime`, `DateTimeOffset` | `datetime.datetime` | | +| `TimeSpan` | `datetime.timedelta` | | +| `Guid` | `uuid.UUID` | | +| `enum` | `enum.Enum` subclass | `None` → `None_` with original name recorded | +| `T?` / nullable reference | `T \| None` | | +| `T[]`, `List`, `IList`, `IEnumerable`, `ICollection` | `list[T]` | Multi-dimensional arrays → nested lists | +| `HashSet`, `ISet` | `set[T]` or `list[T]` | `list` if `T` is not hashable | +| `Dictionary`, `IDictionary` | `dict[K, V]` | | +| `Tuple<...>` | `tuple[...]` | | +| `object`, generic parameter with no constraint | `Any` | | +| Interface | `abc.ABC` subclass | See gotcha 6 | +| Abstract class | `BaseModel` + `ABC` | | +| `System.Drawing.Color` | small hand-written `Color` model (A, R, G, B) | Lives in a support module | +| `Type` | `type[Any]` or `str` | Decide in spike | +| `DataTable`, `Bitmap`, delegates | `Any` / `Callable[..., Any]` | Warn in converter output | +| Generic `Foo` | `class Foo_1(BaseModel, Generic[T])` | Real name in `__bhom__` | + +--- + +## 6. Design decisions (resolved) + +| # | Decision | Outcome | +|---|---|---| +| 1 | Granularity | **One C# type per Python file.** Mirrors the JSON repo and the C# folders, gives clean diffs, and lets the PR Action map class ↔ file 1:1. Revisit only if import time (gotcha 10) is a problem. | +| 2 | Package | A single wrapper package **`bhom_schema`** that contains the assembly folders: `bhom_schema/Geometry_oM/Curve/Line.py`, used as `from bhom_schema.Geometry_oM.Curve.Line import Line`. Avoids `Geometry_oM`, `Structure_oM`... becoming global import names. | +| 3 | Python version | **3.12** minimum. Lets generated generics use `class Group[T: BHoMObject]` (verified), `X \| None` and other modern syntax with no fallbacks. Anyone on older Python can't use the package. | +| 4 | Property names | Keep C# **PascalCase** unchanged. No mapping layer. | +| 5 | Metadata | Carry as much C# metadata as practical (section 7). | +| 6 | Python → C# "aligned" | Agreed: loose, structural alignment (section 8). | + +Still to settle during the spike: how to render `System.Type` (`type[Any]` or `str`), and whether `Quantity` +metadata is a custom `Annotated` marker or a plain description string (leaning marker, see section 7). + +--- + +## 7. Metadata to carry from C# + +Goal: each Python class should say as much about its C# original as possible, so people (and tools such as the +Python → C# converter) get the same meaning, not just the same shape. What the oM actually uses +(attribute lines counted in the local `BHoM` clone, so approximate): + +| C# attribute | Uses | Proposed Python carrier | +|---|---|---| +| `[Description("...")]` | ~3,570 | Class docstring for types; `Field(description="...")` for properties; docstring/comment for enum members | +| Quantity attributes (`[Length]`, `[Stress]`, `[Ratio]`, `[Angle]`, `[Area]`, `[YoungsModulus]`, `[Density]`, ... about 25 kinds) | ~1,000+ | `Annotated[float, Quantity("Length", "m")]` using a small marker type in a support module. The existing generator already reads `QuantityAttribute.SIUnit` and writes "measured in [unit]" into the description, so the data is easy to get | +| `[DisplayText("...")]` | ~195 | `Annotated[..., DisplayText("...")]` or `Field(title="...")` | +| `[DynamicProperty]` | ~54 | Marker on the field, plus a note in the class docstring (these are properties the JSON generator treats specially) | +| Original C# type of a field (`long`, `IList`, `T[]`, ...) | all fields | `Annotated[..., ClrType("long")]` (gotcha 1) | +| Full C# name, namespace, assembly, repo (from `AssemblyDescription`) | all types | `__bhom__` class marker (gotcha 12) | +| Generic parameters and constraints | 34 types | `TypeVar` bounds, with extra constraints listed in `__bhom__` | +| Interfaces / base type | all types | Real Python bases, in a safe order (gotcha 6) | +| Obsolete / version attributes | not in the top 25; check in the spike | `__bhom__["obsolete"]` if present | + +Keep the marker types (`Quantity`, `ClrType`, `DisplayText`) in one small hand-written support module, so the +converter and the Python → C# converter agree on them. Because they are plain `Annotated` metadata, Pydantic +ignores them at runtime and Pyright is unaffected. + +Note on inheritance: the JSON schemas list every inherited property again on each type, because they come +from reflection with inherited members included. In Python, emit **only the properties declared on the class +itself** and let inheritance supply the rest. That is how Python and Pydantic expect it. + +--- + +## 8. Python → C# converter (looser scope) + +Input: the generated and hand-edited Pydantic classes. It reads, via `model_fields`, `typing.get_type_hints`, +the `Annotated` markers (section 7) and `__bhom__`. + +Output: draft C# for the linked PR. "Aligned" means: same type name, namespace and assembly; same base types +and interfaces; same property names and (as far as the metadata allows) types; same enum members; descriptions +and quantities carried across as attributes. It does not reproduce comments, ordering, headers, constructors or +default expressions. A reviewer finishes the PR. + +Because it is looser, most of gotchas 3, 4, 5, 6 do not apply in this direction. Gotchas 1 and 12 do. + +--- + +## 9. Tooling: type hints, Pyright, Pylance, Pydantic + +Someone suggested three practices. Recommendation on each: + +**Type hints everywhere: yes.** The generated classes are fully typed by nature (every field has a type). The +hand-written Python in this repo (the Python → C# converter, support module, tests) should also be fully +typed, and CI should enforce it. + +**Pyright / Pylance: use both; they are not alternatives.** +- **Pyright** is the open-source type checker. It is a command-line tool, so it runs in CI, in pre-commit, or + from a test. It is the one that can *fail a build*. +- **Pylance** is Microsoft's VS Code extension. It is built on Pyright's engine and adds editor features + (autocomplete, hover docs, quick fixes, import suggestions). It is free to use but closed source, and works + in VS Code only. +- So: developers use Pylance in the editor (if they use VS Code); CI runs Pyright as the gate. Commit a + `pyrightconfig.json` so both use the same rules, and pin the Pyright version in the dev requirements so + results don't change under you. +- If a team member uses PyCharm or another editor, Pyright (or its language server) still works for them. + mypy would also work but gains nothing here and understands Pydantic less directly. + +**Pydantic for external inputs: yes, with a precise meaning here.** Our generated classes *are* the Pydantic +models, which gives construction-time validation (wrong field types, missing required fields, wrong interface +implementer are all rejected). In the converter tooling, any structured input it reads (for example config +files or an internal type-list file used by tests) should also be validated through Pydantic models rather than +raw dictionaries. That internal test file is just tooling data; it has nothing to do with BHoM JSON. + +**Evidence [verified]** (Pyright 1.1.414, strict mode, Python 3.12, Pydantic 2.13.5): +- Pyright understands Pydantic constructors. On sample generated-style classes it flagged, with no extra + plugin: a string passed to a `float` field, a missing required argument, an object that isn't an + `IGeometry` passed to an `IGeometry` field, and a misspelled attribute (`.Strt`). +- Generated-style code (ABC interfaces, `ClassVar` marker, `arbitrary_types_allowed`) passed strict mode. +- A class `class Group[T: BHoMObject]` was understood: `Group[W](...).Elements` is `list[W]`. +- One strict-mode warning to design around: `Field(default_factory=list)` inside a generic class reads as + `list[Unknown]`. The generator should emit a typed factory for those, and the spike should confirm strict + mode stays clean over the generated sample. +- Speed on 2,000 files is **not** yet measured; add it to the spike. + +--- + +## 10. Testing approach (no JSON anywhere) + +1. **Generated package imports cleanly:** import every generated module, call `model_rebuild()` on every + model, fail on any `PydanticUserError` or `NameError`. This catches gotchas 5, 7, 8, 9 and 10. +2. **Type-check the generated package:** run Pyright (strict, section 9) over `bhom_schema`. This catches + invalid hints, wrong imports and missing names that the import test can miss. +3. **Structural check against C#:** the converter also dumps a language-neutral list of types (name, + namespace, assembly, base, interfaces, properties with CLR types). A pytest compares each Python class's + `model_fields` and `__bhom__` to that list. +4. **Converter unit tests:** the spike types in section 11 as fixtures. +5. **Python → C# alignment test:** generate C# from the Python, compile it with Roslyn, and compare the same + structural list with the original (loose comparison, per section 8). +6. **Build step (required by repo policy):** the plan must end with building the C# converter projects and + running the Python test suite. + +--- + +## 11. First spike: ten real test types + +Chosen by scanning `BHoM_JSONSchema` (and checking the C# source for inheritance). Together they cover every +gotcha above. Ten slots; slot 8 is a pair, so eleven types, plus the types they depend on (`Point`, `IGeometry`, ...) +which are generated and import-tested automatically. + +| # | Type | Why it is in the spike | +|---|---|---| +| 1 | `dKoP_oM/ProjectIdentification` (C#: `dKoP_Toolkit/dKoP_oM/AdministrativeInformation/ProjectIdentification.cs`) | The new baseline: a flat class of four `virtual string` properties with `= ""` defaults, no `[Description]`, implementing an interface (`IdKoPObject : IObject`, an interface whose name has no `I` prefix) rather than deriving from `BHoMObject`. It also exercises what no other spike type does: a **toolkit repo** (`BHoM/dKoP_Toolkit`, so provenance and repo resolution matter), and a C# folder (`AdministrativeInformation/`) that differs from the namespace folder (`BH.oM.dKoP`, so the Python path is `dKoP_oM/ProjectIdentification.py`) (gotchas 12, 14). It replaces `Point` as the "simplest realistic class": the two have the same shape (flat, interface-only, no `BHoMObject` base), and `Point` is still generated and checked as the type of `Line.Start` and `Line.End`. | +| 2 | `Geometry_oM/Line` | Class referencing another class (`Start`, `End` of type `Point`), implements an interface, has a required `bool` (`Infinite`). Its C# file lives in `Curve/` while the namespace is `BH.oM.Geometry` (gotcha 14). | +| 3 | `Geometry_oM/ICurve` (+ its parents `IGeometry`, `IElement1D`, `IObject`) | Interface chain. `ICurve : IGeometry, IElement1D` is a real multi-interface case for the MRO ordering (gotcha 6), and `IGeometry` has 37 implementers in the schema. Add `Line` as a second implementer in the same test. | +| 4 | `BHoM/BHoMObject` | Base class with defaulted fields (`Name`, `Tags` as `HashSet`, `CustomData` dictionary, `Fragments`). Checks gotchas 3 and 4. | +| 5 | `Structure_oM/Elements/Bar` | Subclass of `BHoMObject` with required fields (`Start`, `End`, `SectionProperty`, ...) after defaulted base fields, plus a quantity-annotated property (`OrientationAngle`) for metadata (section 7). | +| 6 | `Graphics_oM/Enums/GradientCenteringOptions` | Enum with a member named `None` (gotcha 7). | +| 7 | ``BHoM/BHoMGroup`1`` | Generic with a type constraint; the backtick name (gotcha 5). | +| 8 | `Acoustic_oM/Room` **and** `Architecture_oM/Elements/Room` | Same simple name in two assemblies (gotcha 8). Counted as one slot; they are tested together. | +| 9 | `Graphics_oM/Gradient` | A dictionary whose key is a `decimal`, and `System.Drawing.Color` values (gotcha 11, type-mapping table). | +| 10 | `BHoM/ComparisonConfig` | `HashSet` of BHoM objects (`PropertyNumericTolerances`) and several dictionaries, the hashability case (gotcha 11). | + +Optional extra if time allows: `BHoM/UsageLogEntry` (a `DateTime` field, `$date` in the schema) for the +`DateTime` → `datetime` mapping. + +No true reference cycle exists among concrete classes in this set (consistent with the 0 measured in +section 3), so the spike should add one hand-written cyclic fixture pair to exercise the bottom-of-module +import pattern from gotcha 9. + +**Success criteria:** the generated package imports; every model rebuilds; Pyright strict is clean (apart from +agreed exceptions); the structural check passes against the C# type list; the Python → C# draft compiles; +timing for the import and Pyright runs is recorded so the full 2,000-type run can be estimated. + +--- + +## Appendix: experiment log + +Run in a throw-away virtual environment, not part of the repo. + +| Experiment | Result | +|---|---| +| Pydantic subclass with required field after defaulted base | Works | +| Plain dataclass, same shape | `TypeError: non-default argument follows default argument` | +| Interface as `ABC`, field typed with it | Accepts implementers, keeps subclass, rejects others | +| `class M(IFoo, IBar)` with `IBar(IFoo)` | `TypeError` (inconsistent MRO) | +| `class M(IBar, IFoo)` | Works | +| `Enum("E", {"None": 0})` | Works, access via `E["None"]` | +| Enum members `name`, `value` | Work on 3.12 | +| `ClassVar` `__bhom__` on a model | Not treated as a field | +| Mutable list default | Not shared between instances | +| `Generic[T]` model with bound `TypeVar` | Works; wrong element type rejected | +| Field named `type`, then annotation `type[int]` | `TypeError` | +| Class named ``Output`1`` created dynamically / written in source | Works dynamically / `SyntaxError` in source | +| Cross-package cycle with `TYPE_CHECKING` only | Works only if caller has both names in scope; otherwise "not fully defined" | +| Cross-package cycle with bottom-of-module imports + `model_rebuild()` | Works in both import orders | +| 2,000 synthetic models imported | about 3–4 s cold | +| Name clash scan over all 2,007 types and 16,153 properties | Findings in section 3 | +| Python-level reference cycles (interfaces as dead ends), from the schemas | 0 groups | +| Same cycles if interfaces list their implementers | 15 groups, largest 25 types | +| `set` / `dict` keyed by a Pydantic model | `TypeError: unhashable type` | +| `frozen=True` model | Hashable (not adopted) | +| PEP 695 generic `class Group[T: BHoMObject](BaseModel)` | Works on 3.12 | +| Pyright 1.1.414 strict on generated-style code | Clean; flagged bad argument type, missing argument, wrong interface, typo attribute | +| Pyright strict, `Field(default_factory=list)` in a generic class | `list[Unknown]` warning | +| Full corpus (1,800 types, 70 assemblies): generate, import, Pyright strict | 20 s, 4 s, 17 s; 0 cycles; 181 warnings; after the fixes 0 import failures, 0 Pyright errors, 0 structural mismatches | +| `Field(default_factory=dict[str, Tree_1[T]])` inside `Tree_1` | `NameError` at import; `lambda: {}` works | +| Pyright strict, `default_factory=list[T]` in a generic class | Infers `list[str]`, error; `lambda: []` is clean | +| `Enum` instance `E(0)` when no member is 0 | `ValueError`; the field must be required (`Flag` enums accept it) | +| `double[,]` default read by enumeration | One flat sequence of cells; needs row-wise writing | +| `class P[A, B: IOpening[A]]` | Pyright: "TypeVar constraint type cannot be generic"; `IOpening[Any]` works | +| `datamodel-codegen` on `BHoM_JSONSchema` `ProjectIdentification.json` and `GradientCenteringOptions.json` | No base class, no defaults, wire-format fields, doubled enum | +| Pydantic `model_json_schema()` of the probe models | Base class, provenance, enum names, generics, quantities lost | +| `py2many --help` (v0.9) | No C# backend | diff --git a/docs/Converting-All-oM-Classes.md b/docs/Converting-All-oM-Classes.md new file mode 100644 index 0000000..47f04d9 --- /dev/null +++ b/docs/Converting-All-oM-Classes.md @@ -0,0 +1,245 @@ +# Converting all oM classes to Python, and checking the result + +This is a procedure for a full test conversion, plus what a trial run on 2026-10-26 showed. The full-corpus run and hardening is +phase 7 of the coding plan; section 5 records the problems found and fixed so far. + +## 1. What actually gets converted + +The generator reads **compiled assemblies**, not the C# source. It converts every oM assembly it finds in +`%ProgramData%\BHoM\Assemblies` whose description points at `github.com/BHoM/`. Two consequences: + +- The result is only as current as the last build. Build the repos you care about first (for `C:\Github\BHoM`, build + `BHoM.sln`; the post-build step copies the dlls to `ProgramData`). +- You get **every installed BHoM-organisation oM**, not only the ones in `C:\Github\BHoM`. On this machine that was 1,800 types from + about 70 assemblies, including toolkits such as `dKoP_oM`, `Revit_oM` and `Excel_oM`. There is no repo filter option yet. To look + at only the main repo, filter afterwards on `repo == "BHoM/BHoM"` in `manifest.json`. + +## 2. Run the conversion + +Build the generator (from the repository root): + +```powershell +dotnet build .ci/generation/PythonSchemaGeneration.sln -c Release +``` + +Prepare an output package folder. The generated modules import the hand-written `_support` package, so copy it in first: + +```powershell +New-Item -ItemType Directory -Force .ci/unit-tests/_output/corpus/bhom_schema +Copy-Item src/bhom_schema/__init__.py .ci/unit-tests/_output/corpus/bhom_schema/ +Copy-Item -Recurse -Force src/bhom_schema/_support .ci/unit-tests/_output/corpus/bhom_schema/ +``` + +All commands in this guide are for PowerShell (the default terminal on Windows), run one line at a time from the repository root. + +Convert everything (no `--types` filter means all types): + +```powershell +dotnet .ci/generation/PythonSchemaGeneration/bin/Release/net8.0/PythonSchemaGeneration.dll --output .ci/unit-tests/_output/corpus/bhom_schema --manifest .ci/unit-tests/_output/corpus/manifest.json --report .ci/unit-tests/_output/corpus/report.txt +``` + +It takes about 20 seconds and prints one summary line, for example `Generated 1800 of 1800 types, wrote 2033 files, 165 warnings.` +`.ci/unit-tests/_output` is git-ignored, so this is safe to repeat. Running without a filter also **deletes** files of the written +assemblies that are no longer generated, so only point `--output` at `src/bhom_schema` when you mean to update the real package. + +What you get: + +| Output | Meaning | +|---|---| +| `bhom_schema///.py` | One file per C# type, plus empty `__init__.py` files | +| `manifest.json` | A language-neutral description of every generated type (name, bases, properties with C# types, defaults, enum members) | +| `report.txt` | Counts, the assemblies loaded or skipped, types in reference cycles, and every warning | + +## 3. Checks, from cheapest to deepest + +Run them in this order; each one catches a different kind of mistake. + +### Check 1: the summary and the report + +Open `report.txt`. + +- `Types available` should equal `Types generated`. If not, some types failed to convert (the warning says why). +- `Types in reference cycles` is the number of types that import each other. The trial found 0. +- `Loaded assembly` / `Skipped assembly` lines show which dlls were used. A skipped one is usually not a BHoM-organisation oM. +- Read the warning summary. Group them by kind (see section 5); a **new** kind after a BHoM change is the signal that the generator + needs an update. + +### Check 2: compare with the JSON schema repo + +`BHoM_JSONSchema` is generated from the same assemblies with the same folder rule, so the two repos should list the same types. +Compare the set of types per assembly folder. Save this as a scratch script and run it with the virtual environment's Python: + +```python +from pathlib import Path +python = Path(r".ci/unit-tests/_output/corpus/bhom_schema") +json_root = Path(r"C:\Github\BHoM_JSONSchema") +def names(root, suffix): + found = set() + for file in root.rglob("*" + suffix): + parts = file.relative_to(root).parts + if parts[0].startswith((".", "_")) or parts[0] in ("bhom_schema",) or file.name == "__init__.py": + continue + found.add("/".join(parts)[: -len(suffix)].replace("`", "_")) + return found +only_python = names(python, ".py") - names(json_root, ".json") +only_json = names(json_root, ".json") - names(python, ".py") +print(len(only_python), "only in Python;", len(only_json), "only in JSON") +print(sorted(only_python)[:20]) +print(sorted(only_json)[:20]) +``` + +Differences are expected for assemblies one repo has and the other does not (the JSON repo is built from a different set of +repositories) and for a different build date; look at whether the differences cluster in whole assemblies (fine) or are single types +inside an assembly both have (investigate). + +### Check 3: every module imports + +Import every generated module and report the failures. This catches invalid Python, bad names, wrong import order and circular +imports. Scratch script, run from the repository root: + +```python +import importlib, sys, pathlib +root = pathlib.Path(".ci/unit-tests/_output/corpus").resolve() +sys.path.insert(0, str(root)) +failures = [] +for path in sorted((root / "bhom_schema").rglob("*.py")): + if path.name == "__init__.py" or "_support" in path.parts: + continue + module = ".".join(path.relative_to(root).with_suffix("").parts) + try: + importlib.import_module(module) + except Exception as error: + failures.append((module, type(error).__name__, str(error)[:100])) +print(len(failures), "failures") +for failure in failures: + print(failure) +``` + +Run it with `.venv\Scripts\python`. It took about 4 seconds for 1,800 modules. + +### Check 4: type-check the whole package + +Pyright in strict mode finds wrong annotations and defaults that do not match their types. Put a `pyrightconfig.json` in the output +folder: + +```json +{ "include": ["bhom_schema"], "typeCheckingMode": "strict", "pythonVersion": "3.12", "extraPaths": ["."] } +``` + +```powershell +$root = (Get-Location).Path +Set-Location .ci/unit-tests/_output/corpus +& "$root\.venv\Scripts\pyright.exe" --pythonpath "$root\.venv\Scripts\python.exe" +Set-Location $root +``` + +It took about 17 seconds. The goal is `0 errors`. + +### Check 5: structural comparison with the C# reflection + +The C# side wrote `manifest.json` from reflection. The Python reader can read the generated files back and the two must agree +(names, bases, property types, defaults, enum members). This is the strongest check that nothing was lost or mis-written. It is +verified on the fixtures and the 11 spike types by `test_alignment`, but **not yet run on the full corpus**, so treat discrepancies +as findings. + +```python +import sys +from pathlib import Path +sys.path.insert(0, ".ci/generation") +from py2cs.compare import compare_manifests +from py2cs.model import load_manifest +from py2cs.reader import read_package +base = Path(".ci/unit-tests/_output/corpus") +problems = compare_manifests(load_manifest(base / "manifest.json"), read_package(base / "bhom_schema"), allow_extra=False) +print(len(problems), "problems") +print("\n".join(problems[:40])) +``` + +### Check 6 (optional, heavier): the Python to C# round trip + +Draft C# from the generated Python and compile it. Compile errors point at types whose Python could not be turned back into valid C#. + +```powershell +$env:PYTHONPATH = ".ci/generation" +.venv\Scripts\python -m py2cs drafts --root .ci/unit-tests/_output/corpus/bhom_schema --out .ci/unit-tests/_output/roundtrip/corpus +``` + +The Roslyn test that compiles the drafts currently covers only the fixtures and the spike set (`RoundTripTests`), so compiling the +whole corpus needs a new test case. Expect work here; it is not a required check for the Python output. + +### Check 7: read a few files yourself + +Automated checks cannot tell whether a file is a *good* Python class. Open these in +`.ci/unit-tests/_output/corpus/bhom_schema/` and compare with the C# source: + +| File | What to look for | +|---|---| +| `dKoP_oM/ProjectIdentification.py` | Four `str` fields defaulting to `''`, base `IdKoPObject`, `__bhom__` with repo `BHoM/dKoP_Toolkit` | +| `Geometry_oM/Line.py` and `Curve` types | Interface bases in a valid order, default `Point()` objects | +| `Structure_oM/Elements/Bar.py` | Quantity markers (for example `Quantity('Angle', ...)`), enum fields | +| An enum with a member named `None` (for example `Graphics_oM/Enums/GradientCenteringOptions.py`) | Member renamed `None_` and recorded in `renamed_members` | +| `BHoM/BHoMGroup_1.py` | Generic class with a bound | +| A class whose C# properties use `HashSet` of objects | Falls back to `list[...]` with a `ClrType` marker | + +## 4. What "correct" means (suggested pass criteria) + +1. Every available type is generated, and the number of warnings is stable between runs (no new kinds of warning). +2. All modules import. +3. Pyright strict reports 0 errors. +4. The structural comparison reports 0 problems. +5. The type list matches the JSON schema repo for the assemblies both contain. +6. Your hand review of the sample files shows what the C# says. + +## 5. What the trial runs showed + +Both runs used the 1,800 types installed on this machine (2,033 files including `__init__.py`, about 20 s, 0 types in reference cycles). + +| Measure | First run (2026-10-26) | After the fixes below | +|---|---|---| +| Modules that import | 1,789 of 1,800 (11 failed) | **1,800 of 1,800** | +| Pyright strict | 6 errors | **0 errors** | +| Structural comparison (check 5) | not run | **0 problems** | +| Warnings | 165 | 181 | + +The 16 extra warnings are deliberate: fields that used to crash on import are now reported as required (see the bugs below). + +The 181 warnings, by kind: + +| Count | Warning | Is it a problem? | +|---|---|---| +| 104 | A .NET type with no Python equivalent was mapped to `Any` (for example `System.IComparable`, `System.IO.FileAttributes`) | Acceptable for now. The original type is kept in a `ClrType` marker. | +| 32 | Base type `System.Attribute` dropped (the C# attribute classes such as `DisplayTextAttribute`) | Acceptable: the attribute classes are generated as plain classes. Consider excluding attribute classes from the output. | +| 16 | A default value could not be written in Python, so the field is required (for example `System.RuntimeType`) | Review: these fields become required in Python. | +| 14 | The default instance of a type cannot be created in Python because that type has a required field, so the field is required too (the `dKoP` part types holding `AdministrativeInformation`) | Follows from the `DFMACategory` row below; fixing that C# default removes it. | +| 6 | A default collection holds complex items, an empty one was used instead | Review: the default differs from C#. | +| 4 | A generic type could not be instantiated to read its defaults (`PriorityQueue`, `Quantity`) | The fields are then required; acceptable but note it. | +| 2 | `DFMACategory` default is the value 0, which is not a member of the enum | Real C# oddity: `Overview.DFMACategory` defaults to an undefined value, so Python makes it required. | +| 3 | Other (types with no Python equivalent in toolkits, a malformed base) | Review individually. | + +### Bugs found by the first run, and what was done + +All four were in the generator (not in the C# source) and are fixed, with regression fixtures in `FixtureAlpha_oM/Hardening.cs`: + +| Symptom | Cause | Fix | +|---|---|---| +| `NameError: name 'Tree_1' is not defined` (`Tree_1`, `DomainTree_1`, `Graph`) | A generic class named itself in `Field(default_factory=dict[str, Tree_1[T]])`, evaluated while the class was still being defined | Defaults of collections that mention a type parameter are now `lambda: []`, `lambda: {}` or `lambda: set()` | +| `ValueError: 0 is not a valid DFMACategory` (8 `dKoP_oM` types) | The C# default is 0 but the enum has no member 0 | A default that is not an enum member (and the enum is not `[Flags]`) is dropped and reported: the field is required. A field whose default instance cannot be built is required too | +| Pyright: `list[str]` not assignable to `list[T]` (`VennDiagram_1`) | `default_factory=list[T]` was inferred wrongly | Same lambda fix as the first row | +| Pyright: `list[float]` not assignable to `list[list[float]]` (`TransformMatrix`) | A `double[,]` default was written as a flat list | Two-dimensional arrays are written as rows: `lambda: [[...], [...]]` | + +Three more problems turned up while re-running and were fixed too: + +| Symptom | Cause | Fix | +|---|---|---| +| Pyright: "TypeVar constraint type cannot be generic" (`IPanel_2`) | A bound that names another type parameter (`TOpening : IOpening`) is illegal in a Python type parameter | The other parameter is replaced by `Any`: `TOpening: IOpening_1[Any]` | +| `ReadOnlyDictionary` read back as a list of tuples (`IEnvironmentalResult` base) | The type was missing from the dictionary list of the type mapper | Added; the structural comparison also ignores collection flavour in base type arguments, which carry no `ClrType` marker, and line-ending differences in descriptions | +| Empty folders left behind by a clean-up run | `OutputWriter` checked the parent instead of the sub-folder | Fixed (caught by the existing clean-up test) | + +The golden files were regenerated: besides the intended default changes, the committed ones still said `2015 - 2025` while the generator writes `2015 - 2026`. + +Check 6 (the Python to C# round trip on the full corpus) is still not run; the fixture and spike round trips pass, including the new fixture types. + +## 6. After a clean result + +To land the output in the real package, run the same command with `--output src/bhom_schema` (the `_support` folder is already +there and is never touched). Review the resulting diff in git before committing: a first full conversion adds about 2,000 files. diff --git a/docs/Existing-Tools-Evaluation.md b/docs/Existing-Tools-Evaluation.md new file mode 100644 index 0000000..9f91012 --- /dev/null +++ b/docs/Existing-Tools-Evaluation.md @@ -0,0 +1,86 @@ +# Could an existing package do the conversion? Evaluation (2026-10-26) + +Question: can public packages replace the core logic of our two converters (C# to Python, and Python to C#)? The answer after +research and tests is **no** for the core logic. The reasons are below, with what was tested and what was not. + +## 1. What the converters must do + +| Need | Why it matters | +|---|---| +| Keep the class hierarchy and interfaces | `ProjectIdentification` derives from `IdKoPObject`; BHoM relies on `IObject`, `IBHoMObject`, `IFragment`, and so on | +| Keep enum member names (including `None`, written `None_`) | The member name is the contract; the number is not | +| Keep generics and their constraints | `BHoMGroup`, `Tree` | +| Keep C# defaults, descriptions, quantities and display text | Needed for faithful Python and for drafting C# back | +| Carry provenance (C# full name, assembly, repo) | Needed to route the linked PR to the right repository and file | +| Read the **compiled** types | The oM can be built from many repos; reflection already knows every type, base and default | +| Produce **declaration-level** C# (types, properties, enums), not translated method bodies | The oM has no logic, only data shapes | + +## 2. Candidates, by direction + +### Python to C# + +| Candidate | Found | Verdict | +|---|---|---| +| **py2many** (v0.9 installed, repo updated Oct 2026) | `--help` lists C++, Rust, Julia, Kotlin, Lean, Nim, Mojo, D, Dart, Go, V, Zig, SMT. **No C#.** | Not an option. Tested: the installed CLI has no C# switch. | +| **pytocs** (uxmal, Apache-2.0) | Last commit Feb 2023. README examples are Python 2 (`print "Hello"`). Output types are all `object` (listed on its own roadmap). The parser reads annotated assignments (`x: str = ''`) but nothing under `Core/Translate` uses the annotation, so field types would not reach the C#. No PEP 695 generics. Targets `net6.0`. | Statement-level translator of method bodies; wrong tool for typed data shapes. Source reviewed; **not built or run** (see section 4). | +| **cs2python** (isukces) | Last commit Feb 2018, "early stage". C# to Python, simple loops. | Abandoned. | +| **Pydantic JSON Schema, then NJsonSchema/quicktype to C#** | Pydantic side tested (below). C# generator side **not run** (section 4). | Loses the information we need before C# is even reached. | +| **LLM-based** (py2many `--llm`) | Not deterministic. | Not suitable for a CI step that must be reproducible. | + +### C# to Python + +| Candidate | Found | Verdict | +|---|---|---| +| **datamodel-code-generator** (v0.86 installed) | Excellent JSON Schema/OpenAPI to Pydantic generator. It cannot read .NET types, so C# would first have to be exported to a schema. Tested on the real `BHoM_JSONSchema` files: see below. | Fails our needs; would also make the JSON repo a dependency, which we decided against. | +| **NJsonSchema / NSwag** | C# to schema and schema to C#. Same schema-level loss as above. | Not run. | +| **PythonNetStubGenerator, pythonstubs, clr-stubs** | Produce `.pyi` stubs so Python can *call* .NET through pythonnet. | Wrong purpose: stubs for calling .NET, not Pydantic data classes. | +| **Roslyn-based transpilers** (Rosetta, Transpose) | Target TypeScript and JavaScript. | No Python target. | +| **Reflection to Pydantic** | A blog shows exactly our approach for Java. No packaged .NET equivalent found. | Confirms writing our own reflection generator is the standard route. | + +## 3. Tests that were run + +### 3.1 Pydantic to JSON Schema (the first half of any schema route to C#) + +Using a probe model file shaped like our generated output (base interface, `None_` enum member, generic, `Annotated` quantity, +`ClassVar` provenance marker): + +| Information | After `model_json_schema()` | +|---|---| +| Base class `IdKoPObject` | **Lost** (plain `object` schema) | +| `__bhom__` provenance (C# name, repo) | **Lost** | +| Enum `None_ = 0`, `Center = 1`, `Edge = 2` | **Names lost**, only `enum: [0, 1, 2]` remains | +| `Group[T]` generic | **Lost** (`items: {}`) | +| `Quantity` marker | **Lost** | +| `Tags: list[str]` default factory | Default dropped | + +Any C# generated from that schema would be a flat property bag with no hierarchy, no enum names and no repo to send the PR to. + +### 3.2 JSON Schema to Pydantic (the C# to Python schema route) + +`datamodel-codegen` on the real `BHoM_JSONSchema/dKoP_oM/ProjectIdentification.json` and `Graphics_oM/Enums/GradientCenteringOptions.json`: + +- `ProjectIdentification`: no base class `IdKoPObject`, no defaults (every field `str | None` and required), and BHoM-JSON noise + fields (`_t`, `_bhomVersion`). +- `GradientCenteringOptions`: produced **two copies of the enum plus a `System.Enum`/`System.Type` wrapper model** and a `RootModel` + union, because the schema describes the BHoM-JSON wire format, not the type. + +It is a tool for wire formats; our target is the type model. + +## 4. What could not be tested, and why + +The sandbox blocked building the cloned pytocs (`dotnet build` of an unreviewed third-party repository), and I did not work +around that. The same applies to running NJsonSchema or quicktype, which would also be downloaded third-party code. The verdicts +for those rest on reading their source and documentation, plus the schema tests above (which show the loss happens before they +run). To run them anyway, the user can approve the build and I will repeat the probe: `python probe/probe_models.py` through +`pytocs` (expect `object`-typed output and no field types), and the Pydantic schema through NJsonSchema. + +## 5. Conclusion + +1. No existing package converts Python to C# with typed declarations, and none targets C# from a Python class model. +2. The only maintained broad transpiler (py2many) does not have a C# backend. +3. The only existing C# target (pytocs) is aimed at method bodies and produces untyped `object` code. +4. Every schema-based route drops hierarchy, enum names, generics and provenance, so it cannot meet the requirements. +5. For C# to Python, reflection over the compiled oM is the standard method and no package replaces it. + +So the core logic stays ours. What we **do** reuse already: BHoM's own helpers (`IsOmNamespace`, `IsInOrg`, `RelativeSchemaId`, +`BHoMFolder`), Pydantic for the model classes, Roslyn for compiling the C# in the tests, and Pyright for checking the output. diff --git a/docs/Generating-the-Real-Package.md b/docs/Generating-the-Real-Package.md new file mode 100644 index 0000000..83e1f18 --- /dev/null +++ b/docs/Generating-the-Real-Package.md @@ -0,0 +1,233 @@ +# Generating the real `bhom_schema` package + +[Converting-All-oM-Classes.md](Converting-All-oM-Classes.md) is a **trial run** into the git-ignored `_output` folder. This guide +is the **real run**: the generated classes land in `src/bhom_schema`, the package that Python projects install, and you commit +them. All commands are PowerShell, run one at a time from the repository root. + +## 0. Current state: only the CSharpSchema_Test classes + +For now `src/bhom_schema` holds only the classes of the [CSharpSchema_Test](https://github.com/BHoM/CSharpSchema_Test) repository, +plus the five BHoM base classes they import (`BHoMObject`, `IBHoMObject`, `IObject`, `IFragment`, `IImmutable`). Nothing else from +BHoM/BHoM is converted yet. The `--repos` option does this: it converts the assemblies whose description points at the named +repositories, and their dependencies. A run with `--repos` only cleans up the assembly folders of those repositories, so the +rest of a dependency's folder (for example `BHoM/`) is never deleted. + +Build CSharpSchema_Test (`dotnet build CSharpSchema_Test.sln -c Release -p:SkipAssemblyCopy=true`), copy its dlls with `BHoM.dll` and +`Quantities_oM.dll` into an empty folder, then: + +```powershell +dotnet .ci/generation/PythonSchemaGeneration/bin/Release/net8.0/PythonSchemaGeneration.dll --output src/bhom_schema --assemblies --repos BHoM/CSharpSchema_Test +``` + +The rest of this guide describes the full run over every installed assembly. + +## 1. Read this first: what a real run does + +- It writes about 2,000 files into `src/bhom_schema` (one `.py` per C# type plus `__init__.py` files). The folder + `src/bhom_schema/_support` and the file `src/bhom_schema/__init__.py` are hand-written and are never touched. +- On every run it **deletes** generated files of the assemblies it wrote that are no longer generated, so removing a C# type + removes its Python file. Anything you hand-edit inside a generated folder is lost; never edit generated files. +- The input is whatever is compiled in `%ProgramData%\BHoM\Assemblies` on this machine. That folder holds every BHoM toolkit you + have built, at whatever commit you built it, so a local run is only as current and as clean as your last builds. +- The generator needs no internet and does not read `BHoM_JSONSchema`. + +### Known limits of the generated package + +The trial run (see section 5 of the trial guide) found four generator bugs, now fixed; a full run imports all 1,800 modules, passes +Pyright strict and the structural comparison. What remains is deliberate and shows up as warnings: + +| Limit | Effect | +|---|---| +| `Overview.DFMACategory` defaults to the undefined enum value 0 in C# | The field is required in Python, and so are the fields holding a `dKoP` `Overview` or `AdministrativeInformation` (for example `UpperFloor()` needs its `AdministrativeInformation`) | +| About 16 other defaults cannot be written (for example `System.RuntimeType`) | Those fields are required | +| About 104 .NET types have no Python equivalent | Mapped to `Any`, with the C# type kept in a `ClrType` marker | + +## 2. Before you start + +1. **Build the C# you want converted.** For the main repo, build `C:\Github\BHoM\BHoM.sln`; the post-build step copies the dlls to + `%ProgramData%\BHoM\Assemblies`. For a toolkit (for example `C:\Github\dKoP_Toolkit`), build it too. A toolkit that is not built + is not converted. +2. **Decide the scope.** There is no repo filter, so the run converts every installed assembly whose description points at + `github.com/BHoM/` (about 70 assemblies on the trial machine). To use another organisation or a different assembly folder, use + the options in section 3. +3. **Use a clean branch** off `develop`, so the result is one reviewable commit: + +```powershell +git switch develop +``` + +```powershell +git switch -c schema-initial-generation +``` + +4. **Check the working tree is clean** (an untracked `docs` file is fine): + +```powershell +git status --short +``` + +## 3. Run the conversion into `src` + +Build the generator: + +```powershell +dotnet build .ci/generation/PythonSchemaGeneration.sln -c Release +``` + +Generate straight into the package, with a manifest and report kept outside the repository's tracked files: + +```powershell +New-Item -ItemType Directory -Force .ci/unit-tests/_output/real | Out-Null +``` + +```powershell +dotnet .ci/generation/PythonSchemaGeneration/bin/Release/net8.0/PythonSchemaGeneration.dll --output src/bhom_schema --manifest .ci/unit-tests/_output/real/manifest.json --report .ci/unit-tests/_output/real/report.txt +``` + +You should see a line like `Generated 1800 of 1800 types, wrote 2033 files, 165 warnings.` in about 20 seconds. + +Options when the default is not what you want: + +| Option | Meaning | +|---|---| +| `--assemblies ` | Read dlls from another folder instead of `%ProgramData%\BHoM\Assemblies` | +| `--org BHoM,Other` | Organisations whose assemblies are included (matched against the repo URL in each assembly description) | +| `--types A,B` | Convert only these types (a partial run: **do not** use this for the real package, it deletes nothing but gives an incomplete result) | + +Open `.ci/unit-tests/_output/real/report.txt` and read the `Loaded assembly` and `Skipped assembly` lines. If a toolkit you expect +is missing, it was not built or its description has no BHoM repo URL. + +## 4. Check the result + +All checks run against the real package. The first three are fast. + +**Count.** `Types available` and `Types generated` in the report must be equal, and the number of files in `src/bhom_schema` +should be close to the number in the summary line: + +```powershell +(Get-ChildItem src/bhom_schema -Recurse -Filter *.py).Count +``` + +**Import every module.** Save this script as `.ci/unit-tests/_output/real/import_all.py` (git-ignored), then run it: + +```python +import importlib, pathlib, sys +root = pathlib.Path("src").resolve() +sys.path.insert(0, str(root)) +failures = [] +for path in sorted((root / "bhom_schema").rglob("*.py")): + if path.name == "__init__.py" or "_support" in path.parts: + continue + module = ".".join(path.relative_to(root).with_suffix("").parts) + try: + importlib.import_module(module) + except Exception as error: + failures.append((module, type(error).__name__, str(error)[:100])) +print(len(failures), "failures") +for failure in failures: + print(failure) +``` + +```powershell +.venv\Scripts\python .ci/unit-tests/_output/real/import_all.py +``` + +**Type-check.** The repository's `pyrightconfig.json` already includes `src` in strict mode: + +```powershell +.venv\Scripts\python -m pyright +``` + +**Structural comparison** of the C# manifest with the Python read back from the files: + +```python +import sys +from pathlib import Path +sys.path.insert(0, ".ci/generation") +from py2cs.compare import compare_manifests +from py2cs.model import load_manifest +from py2cs.reader import read_package +problems = compare_manifests( + load_manifest(Path(".ci/unit-tests/_output/real/manifest.json")), + read_package(Path("src/bhom_schema")), + allow_extra=False, +) +print(len(problems), "problems") +print("\n".join(problems[:40])) +``` + +**The existing test suite**, which now also sees the real package: + +```powershell +.ci\unit-tests\run-all.ps1 +``` + +**Determinism.** Run the generator a second time (same command as above). Then: + +```powershell +git status --short +``` + +Nothing may change between the two runs apart from what the first run already added. If files change, the output is not +deterministic and must not be committed. + +**Try it as a user.** The package is installed in editable mode in the project `.venv`, so this works immediately: + +```powershell +.venv\Scripts\python -c "from bhom_schema.dKoP_oM.ProjectIdentification import ProjectIdentification as P; print(P(ProjectName='Demo'))" +``` + +## 5. Success criteria for committing + +1. `Types available` equals `Types generated` in the report. +2. Every module imports (0 failures). +3. Pyright strict reports 0 errors. +4. The structural comparison reports 0 problems. +5. `run-all.ps1` finishes green, including the build step. +6. A second run produces no further changes. +7. The warning list has been read (see the warning kinds in the trial guide) and no new kind appeared. + +## 6. Commit + +Review the size of the change before staging; a first run adds about 2,000 new files and no edits to hand-written ones: + +```powershell +git status --short | Select-String -NotMatch "^\?\? src/bhom_schema/" +``` + +That should print nothing except unrelated files you expect (for example your docs). If it lists a modified file under +`src/bhom_schema/_support` or `src/bhom_schema/__init__.py`, stop: the generator must not change them. + +```powershell +git add src/bhom_schema +``` + +```powershell +git commit -m "Generate bhom_schema from the BHoM oM" +``` + +A commit message hook or the repository's own conventions may require an issue reference; follow them. Push and open a pull +request only when you are ready for review, because the diff is too large to read in full: reviewers should rely on the report +and checks above plus a spot check of a few files (see check 7 in the trial guide). + +## 7. Updating later + +Rebuild the C# you changed, repeat section 3, then review `git diff`. The diff should contain only the types that changed in C# +(plus removals of types deleted in C#). A diff that touches many unrelated files means the installed dlls differ from the last +run, for example one toolkit built from a different branch; fix the inputs rather than committing the noise. + +The weekly generation workflow (phase 8 of the plan) will do exactly this in a clean environment: clone the repositories in +`Included-repositories.txt`, build them, run this command and commit the result straight to `develop` (no pull request, since the +C# is the source of truth). Until that exists, the package is only as reproducible as the machine it was generated on, so note +the commits of the repositories you built in the pull request description. + +## 8. If something goes wrong + +| Symptom | Likely cause and fix | +|---|---| +| `Missing --output` | The option is `--output `, not a positional argument | +| `Generated 0 of 0 types` | `%ProgramData%\BHoM\Assemblies` is empty or the `--org` filter excludes everything; build BHoM and check `report.txt` | +| A toolkit's types are missing | The toolkit was not built, or its assembly description has no `github.com//` URL | +| Many unexpected files deleted in `git status` | The installed assemblies are a smaller set than when the package was last generated; restore with `git restore src/bhom_schema` and rebuild the missing toolkits | +| Different output on every run | Do not commit; run the determinism check and report which files differ | +| You ran with `--types` by mistake | `git restore src/bhom_schema` and `git clean -fd src/bhom_schema`, then run without `--types` | diff --git a/docs/Private-Repositories.md b/docs/Private-Repositories.md new file mode 100644 index 0000000..cb79cb1 --- /dev/null +++ b/docs/Private-Repositories.md @@ -0,0 +1,75 @@ +# Private C# repositories: what would need to change + +**Status: not done. For now the system only works with public C# repositories in the BHoM organisation** +(today that is [BHoM/CSharpSchema_Test](https://github.com/BHoM/CSharpSchema_Test)). This note records what breaks for private +repositories, for example the ones in the BuroHappoldEngineering organisation, and the options, so the work can be picked up later. + +## What breaks for private C# repositories + +| Part | Why it fails | +|---|---| +| Hourly sweep in `linked-csharp-pr.yml` | It reads the C# pull requests with this repository's `GITHUB_TOKEN`, which can only read this repository and public repositories | +| `.github/scripts/Clone-Repositories.ps1` | It clones anonymously over HTTPS | +| The token step in `linked-csharp-pr.yml` | It passes `owner: ${{ github.repository_owner }}`, so it only works for C# repositories in the same organisation as this one. Repositories in another organisation fail even without the sweep | +| Public logs and artifacts | This repository is public, so its Actions logs and uploaded artifacts are public. The weekly generation uploads a report and a manifest that list class names, warnings and build output. Private code built or generated here would be exposed | +| `src/bhom_schema` | Classes generated from a private repository and committed here would be public | + +The last two are the real problem. Private classes cannot be generated into, or built inside, the public repository, however the +tokens are arranged. + +## Decision 1: where do the private classes live? + +| Option | What it means | Verdict | +|---|---|---| +| A. The same public repository | Private classes are generated here | Not acceptable: it leaks them | +| B. A second, private deployment | A private repository (for example `BuroHappoldEngineering/BHoM_PythonSchema-private`) holds the private classes, runs the same workflows, and has its own `Converted-repositories.txt`. This repository keeps the public C# repositories only | **Recommended** | +| C. One private repository for everything | The public BHoM classes are generated in a private repository too | Possible, but the public BHoM Python classes would no longer be public | + +**Sharing the code in option B.** Copying the tooling would let the two repositories drift apart. Instead: + +- The public repository keeps `py2cs`, the generator and the patcher. +- The three workflows become reusable (`workflow_call`), which a private repository can call, pinned to a commit. +- The private repository holds only its repository lists, its own `src/bhom_schema`, thin workflows that call the public ones, and + its secrets. + +This is a refactor of the workflows, not new logic. + +## Decision 2: how do the tokens work? + +This applies to every deployment, because each one reads and writes C# repositories with a GitHub App token. One +`actions/create-github-app-token` step makes a token for one owner only. + +| Option | How it works | Pros | Cons | +|---|---|---|---| +| 1. One GitHub App, matrix per repository | Take the owner from each repository's `Owner/Name`. Make one token per repository in a matrix leg. The sweep becomes a matrix plus one final job that combines the results into a single status | Same GitHub-maintained action. No new secrets in the C# repositories. Small change | The App must be installed in both organisations, and its visibility must be "Any account" (a private App can only be installed in its own organisation). The sweep is more complex | +| 2. One App per organisation | Same as 1, with one App in BHoM and one in BuroHappoldEngineering. Each deployment holds the secrets of its own App | Strongest separation: nothing crosses organisations. Natural with option B | Two Apps to set up and maintain | +| 3. `py2cs` makes the tokens itself | `py2cs` signs a JWT with the App key and asks GitHub for an installation token per repository. The sweep stays one simple loop | One step, any number of owners | Needs a JWT/crypto library, and the code that holds the private key grows. Best avoided | +| 4. The C# repositories report their own state | A workflow in each C# repository, on pull request merge or close, sets the status on the Python PR | Event-driven: no hourly polling, no cross-organisation reads | Needs a workflow and secrets in every C# repository. The public repositories, BHoM/BHoM in particular, cannot be changed yet | +| 5. A personal access token or bot account | One long-lived token that can read the repositories | Simplest | Tied to a person or account, long-lived, and hard to scope across two organisations. Not recommended | + +## Recommendation + +1. Run private C# in a separate private deployment (option B), and make the public tooling reusable so both share one code base. +2. Use one GitHub App per organisation (token option 2). If a single App is preferred, token option 1 needs it set to "Any account". +3. In both cases: + - take the owner from each repository's `Owner/Name`; + - make the cloning script use a token for private repositories; + - make the sweep a per-repository matrix with one final job; + - keep uploaded artifacts and verbose logs out of any deployment that handles private code. + +Token option 4 is a good later addition for private repositories, because their owners control them. It cannot replace the sweep for +BHoM/BHoM. + +## Questions to settle before any code + +- Is a separate private deployment (option B) acceptable? +- One GitHub App per organisation, or a single App installed in both? + +## Where this shows up in the code today + +These places assume public repositories in one organisation, and would change: + +- `.github/workflows/linked-csharp-pr.yml`: `owner: ${{ github.repository_owner }}` in both token steps, and the `sweep` job. +- `.github/scripts/Clone-Repositories.ps1`: anonymous `git clone`. +- `.github/workflows/python-schema-generation.yml`: uploads the report and manifest as a public artifact. +- `Converted-repositories.txt` and `Included-repositories.txt`: one list each, for one deployment. diff --git a/docs/Python-Packages-Primer.md b/docs/Python-Packages-Primer.md new file mode 100644 index 0000000..bd5d094 --- /dev/null +++ b/docs/Python-Packages-Primer.md @@ -0,0 +1,217 @@ +# Python packages: a primer for reading `py2cs` + +This is for someone who knows C# well and has not built a Python package before. It explains only what you need to read +[`.ci/generation/py2cs`](../.ci/generation/py2cs), with C# comparisons where they help. + +## 1. The vocabulary + +| Python term | What it is | Closest C# idea | +|---|---|---| +| **module** | One `.py` file. Its name is the file name without `.py`. | A `.cs` file (but the file *is* the namespace member, there is no `namespace` keyword) | +| **package** | A folder of modules that contains a file called `__init__.py`. | A namespace backed by a folder, or a small assembly | +| **import** | Loads a module and gives you names from it. | `using` plus a project reference | +| **`sys.path`** | The list of folders Python searches when you `import` something. | The assembly search path | +| **virtual environment** (`.venv`) | A private copy of Python plus the libraries this project needs. | A per-project `packages` folder | +| **`pyproject.toml`** | Project settings: name, dependencies, tool configuration. | A `.csproj` | + +There is no compile step. Python reads the `.py` files directly when you run them. + +## 2. What makes `py2cs` a package + +``` +.ci/generation/ + py2cs/ + __init__.py <- its presence makes the folder a package + __main__.py <- lets you run the package: python -m py2cs + model.py + reader.py + ... +``` + +- **`__init__.py`** runs when the package is first imported. In `py2cs` it only holds a one-line description, so it does nothing. + (A package can put code there to choose what `import py2cs` exposes; this one does not.) +- **`__main__.py`** is what runs for `python -m py2cs `. The `-m` flag means "run this package as a program". + The bottom of the file has the usual guard: + + ```python + if __name__ == "__main__": + sys.exit(main()) + ``` + + `__name__` is `"__main__"` only when the file is run as a program, not when something imports it. This keeps the + command-line code from running during tests. `sys.exit(main())` makes the number returned by `main()` the process exit code, + like `return 1;` from a C# `Main`. + +## 3. How Python finds `py2cs` (the part that confuses most people) + +`py2cs` is **not installed**. It has no `pyproject.toml` of its own, so `pip` does not know about it. Python can import it only +if the folder that *contains* `py2cs/`, which is `.ci/generation`, is on `sys.path`. This repository arranges that in two places: + +- **pytest:** `pyproject.toml` has `pythonpath = ["src", ".ci/generation", ".ci/unit-tests/python"]`, so tests can write + `from py2cs.reader import read_package`. +- **Command line:** `run-all.ps1` sets the environment variable `PYTHONPATH` to `.ci/generation`. If you run it by hand you + must do the same, or run it from inside `.ci/generation`: + + ```powershell + $env:PYTHONPATH = ".ci/generation" + .venv\Scripts\python -m py2cs --help + ``` + +If you ever see `ModuleNotFoundError: No module named 'py2cs'`, the folder is not on `sys.path`. + +There are two different packages in this repository. Do not mix them up: + +| Package | Where | Purpose | Installed? | +|---|---|---|---| +| `bhom_schema` | `src/bhom_schema` | The **output**: generated Pydantic classes for the C# oM. | Yes, `pip install -e .` from the root `pyproject.toml` | +| `py2cs` | `.ci/generation/py2cs` | A **tool**: reads `bhom_schema` and writes C#. | No, only put on the path | + +## 4. Imports as used in `py2cs` + +```python +import ast +from pathlib import Path +from py2cs.model import Manifest, TypeInfo +``` + +- `import ast` loads the whole module; you then write `ast.parse(...)`. +- `from pathlib import Path` takes one name out of a module. +- `from py2cs.model import TypeInfo` means: package `py2cs`, module `model` (the file `model.py`), name `TypeInfo`. + All imports inside `py2cs` are written this way, starting from the package name (called *absolute* imports). +- Standard library modules (`ast`, `json`, `re`, `argparse`, `pathlib`) ship with Python and need no installation. + +Python runs a module's code top to bottom the first time it is imported. Two modules that import each other can therefore fail. +`py2cs` avoids that: the dependencies only point one way (see section 7). + +## 5. Python features you will meet on almost every page + +**Type hints.** They are written after a colon and `->`, and look like C# types: + +```python +def locate(repo_root: Path, info: TypeInfo) -> Located: +``` + +They are not enforced when the program runs. They are checked by **Pyright** (the project runs it in strict mode) and they help the +editor. `list[str]` is `List`, `dict[str, int]` is `Dictionary`, `str | None` is a nullable `string`, and +`Callable[[Path], Path | None]` is a `Func`. + +**`from __future__ import annotations`.** The line at the top of most files. It makes type hints be read as text and resolved +later, so a class can mention a type defined further down the file. + +**Dataclasses** (`@dataclass`). A decorator, which is a function that wraps the class below it, so a `@dataclass` class gets a +constructor, equality and a readable `repr` for free from its field list. This is how `model.py` defines `TypeInfo`, `PropertyInfo` +and the rest. They behave like C# `record`s: + +```python +@dataclass +class PropertyInfo: + name: str + clr_type: str + description: str | None = None +``` + +A field with a default must come after fields without one. A list default must not be written as `[]` (every instance would share +one list), so the code uses `field(default_factory=lambda: [])`, which means "make a fresh list for each instance". + +**Comprehensions** are one-line loops that build collections: + +```python +names = [p.name for p in info.properties if p.has_default] +by_name = {p.name: p for p in info.properties} +``` + +The first is `info.properties.Where(p => p.HasDefault).Select(p => p.Name).ToList()`; the second is `ToDictionary`. + +**f-strings** are interpolated strings: `f"{clr_name}: missing"` is `$"{clrName}: missing"`. + +**Indentation is syntax.** Blocks are defined by indentation, not braces, and `if`, `for`, `def` and `class` lines end with a colon. + +**Names.** Functions and variables are `snake_case`, classes are `PascalCase`. A leading underscore (`_name`) means "internal, do +not use from outside" by convention only; nothing enforces it. `self` is the explicit first parameter of a method, like `this`, +but you must write it. + +**Exceptions** use `raise` and `try ... except`, like `throw` and `try ... catch`. `ResolveError` in `resolve.py` is a custom one. + +**Paths.** `pathlib.Path` objects replace string handling: `root / "Demo_oM" / "Line.cs"` joins paths, `path.read_text()` reads a +file, `path.stem` is the name without extension, `path.rglob("*.cs")` searches recursively. + +## 6. The standard-library parts that matter here + +- **`ast`**: parses Python source into a tree of node objects without running it. `reader.py` uses it to read the generated + classes. Useful nodes: `ClassDef` (a class), `AnnAssign` (`Name: str = ''`, a field), `Subscript` (`list[str]`), `BinOp` with a + `BitOr` operator (`str | None`), `Call` (`Field(...)`), `Constant` (a literal). `ast.literal_eval` turns a literal's source text into + a value, and `ast.unparse` turns a node back into source text. +- **`argparse`**: builds the command line (`python -m py2cs plan --base-root ...`). `__main__.py` defines one sub-command per + action: `manifest`, `diff`, `plan`, `drafts`, `resolve`. +- **`json`**: reads and writes the manifest files. +- **`re`**: regular expressions, used in `locate.py` to find `class Name` declarations in C# files. + +## 7. A map of the package + +Read the files in this order. Each one only imports files above it in the table, so you can understand them one at a time. + +| Order | File | What it does | Ideas it uses | +|---|---|---|---| +| 1 | `model.py` | The data shapes: `TypeInfo`, `PropertyInfo`, `MemberInfo`, and reading and writing the manifest JSON. The same shape the C# generator writes. | dataclasses, `json` | +| 2 | `reader.py` | Turns the generated Python files into `TypeInfo` objects by parsing them with `ast`. The `Converter` class turns an annotation such as `list[str] \| None` back into a C# type name such as `System.Collections.Generic.List`. | `ast`, classes, recursion | +| 3 | `compare.py` | Compares two manifests and returns a list of differences (used by tests). | functions, sets | +| 4 | `diff.py` | Compares the manifest at the PR base with the one at the PR head: added, removed and changed types. | dataclasses | +| 5 | `resolve.py` | Decides which GitHub repository owns a class, from the repo recorded in the class or a repository listing. | exceptions | +| 6 | `locate.py` | Finds an existing `.cs` file by searching for the declaration, and chooses a place for a new one. | `pathlib`, `re` | +| 7 | `csharp.py` | The C# writer: type names, default values, properties, constructors, whole files and replacement files. The largest file. | string building, `ast` | +| 8 | `plan.py` | Combines the pieces: for each change, which repo, which file, new or replacement, and the C# text. | dataclasses, callables | +| 9 | `report.py` | Writes the Markdown that goes into the C# pull request. | string building | +| 10 | `__main__.py` | The command line that calls everything above. | `argparse` | + +The data flow is: + +``` +bhom_schema/*.py --reader--> manifest --diff--> changes --plan--> C# files + report + ^ + resolve (repo) + locate (file) + csharp (text) +``` + +Nothing writes into a C# checkout: `plan.py` only reads the clones and returns text, and `__main__.py` writes the results into a +separate output folder. + +## 8. Running and checking it + +All commands are from the repository root, with the virtual environment's Python: + +```powershell +.venv\Scripts\python -m pytest .ci/unit-tests/python/test_py2cs -q +``` + +runs the `py2cs` tests (about 37 of them). To see one command work: + +```powershell +$env:PYTHONPATH = ".ci/generation" +.venv\Scripts\python -m py2cs manifest --root .ci/unit-tests/_output/spike/bhom_schema --out spike-manifest.json +``` + +(the spike folder exists after `dotnet test` has run; see `run-all.ps1`). + +```powershell +.venv\Scripts\python -m pyright +``` + +type-checks the package in strict mode. + +Useful habits for reading unfamiliar Python: + +- Run `python -i` or add a `print(...)` and look at real values. Python has no compile step, so experiments are cheap. +- `dir(obj)` lists what an object offers, and `help(obj)` shows its documentation. +- The tests in `.ci/unit-tests/python/test_py2cs` double as examples: each one builds a tiny package, calls a function and checks + the result, which is often the quickest way to see what a function expects. + +## 9. Things that look odd but are normal + +- `__pycache__` folders appear next to the `.py` files. They hold cached compiled bytecode and are git-ignored. +- `# pyright: ignore[...]` or `# type: ignore` comments tell the type checker to skip a line. +- Files start with a triple-quoted string. That is a **docstring**, Python's equivalent of a `[Description]` or XML comment, read by + tools such as `help()`. +- There is no `public`, `private` or `static` keyword. Everything is public, and top-level functions are used where C# would + use a static class. +- `*` and `**` in calls spread a list or dictionary into arguments, like `params`. +- Python modules are objects. You can pass a function around like a delegate, which is how `plan.py` receives the function that finds + a clone folder. diff --git a/docs/Reusing-oM-Classes.md b/docs/Reusing-oM-Classes.md new file mode 100644 index 0000000..25df602 --- /dev/null +++ b/docs/Reusing-oM-Classes.md @@ -0,0 +1,60 @@ +I've checked the oM for a direct project → building → storey → slab containment chain but found none, so I'm now looking into generic container classes and cross-discipline models instead. + +The corpus has no ready-made project → building → storey → slab chain. The oM has no `Project`, `Site` or `Storey` container. `Level` and `Building` exist, but no other class has a property of those types, so none of them are nested in anything. + +Containment between levels would come from composing the classes below. In `bhom_schema`, paths follow `//.py`. + +## What exists at each level + +| Level | Candidates | Notes | +|---|---|---| +| **Project** | `LifeCycleAssessment_oM/LifeCycleAssessment/ProjectLifeCycleAssessment` | The only class with a project-to-elements link: `LCAScope` (project name, area, building lifespan, `Location`) plus `Elements: List[IElementM]`. | +| | `dKoP_oM/dKoP/ProjectIdentification`, `ProjectTeam` | Reference, name and location strings; client, architect and engineer contact details. | +| | `LifeCycleAssessmentScope`, `Inspection_oM/Audit`, `MidasCivil_oM/Metadata` | Project number or name as loose fields. | +| **Site** | `Environment_oM/Climate/Location`, `Ground_oM` (`Borehole`, `Stratum`), `OpenStreetMap_oM` | Only site context; no site container. | +| **Building** | `Environment_oM/Elements/Building` | `Location`, `Elevation`, `Type`. Holds no reference to spaces or panels. | +| **Storey** | `Spatial_oM/SettingOut/Level` (`Name`, `Elevation`), `Spatial_oM/SettingOut/Grid` | `Level` is the only real storey class. Nothing references it. | +| | `dKoP_oM/dKoP/SettingOut.BuildingLevel` | A string. | +| | `ETABS_oM/ETABSId.Story` and `Revit_oM/ViewPlan.LevelName` | Adapter-specific strings. | +| **Space / room** | `Environment_oM/Elements/Space`, `Zone`; `Architecture_oM/Elements/Room`, `Ceiling` | Perimeter plus location. `Space.Zones` holds names. | +| **Slab / floor** | `Physical_oM/Elements/Floor` | Surface location, `Construction`, `Openings`. | +| | `Structure_oM/Elements/Panel`, `Surface` | `Property` is an `ISurfaceProperty`. The slab types are `ConstantThickness`, `Ribbed`, `Waffle`, `HollowCore`, `SlabOnDeck`, `ToppedSlab`, `Layered` and others under `Structure_oM/SurfaceProperties`. | +| | `dKoP_oM/dKoP/UpperFloor` | A product data sheet for a floor, not a model object. | +| **Other elements** | `Physical`: `Wall`, `Roof`, `Column`, `Beam`, `Bracing`, `Door`, `Window`, `Void`, `Pile`, `PadFoundation`. `Structure`: `Bar`, `Node`, `RetainingWall`. `Facade`: `CurtainWall`, `Panel`. `MEP`: `Duct`, `Pipe`, `AirHandlingUnit`, fixtures. | | +| **Below element** | `Physical/Constructions/Construction` → `Layer` → `Material`; `Physical/Materials/MaterialComposition` | | + +## Routes (hierarchies that actually exist) + +- **Route A, Physical (building model).** + - `Floor`/`Wall`/`Roof` → `Openings: List[IOpening]` (`Door`, `Window`, `Void`) → `Construction` → `Layer` → `Material`. + - `Column`, `Beam`, `Bracing` and `Pile` sit beside them. `Level`, `Grid` and `Room` are not wired in. + - This is the closest to "slab etc.", and these classes are the most reusable. +- **Route B, Environment (energy and analysis model).** + - `Building` (+`Location`) and `Space` (+`Perimeter`) are separate. + - `Panel` → `Opening` → `Edge` carries `ConnectedSpaces`, a list of strings. + - Containment is by name, not by object. +- **Route C, Structure (analytical model).** + - `Panel` or `Surface` → `ISurfaceProperty` → `Material`. + - `Bar` → `Node`. + - `RetainingWall` → `Stem` + `PadFoundation`. + - Groups use `BHoMGroup[T]`; `DesignGroup` holds member ids. + - A structural slab is a `Panel` with a slab property. +- **Route D, Facade.** `CurtainWall` → `Panel` → `Opening` → `FrameEdge`. +- **Route E, dKoP (product data).** + - Every part type (`UpperFloor`, `FlatRoof`, `ExternalWall`, `Window` and others) carries `AdministrativeInformation`, which holds `ProjectIdentification` and `ProjectTeam`. + - `PartIdentification.BuildingReference` and `SettingOut.BuildingLevel` link parts to a building and level as strings. + - It is the only model that names project, building and level together, but it is one document per part. +- **Route F, LCA.** `ProjectLifeCycleAssessment` → `Elements: List[IElementM]`. `IElementM` is the interface for anything with a material, which covers all of Physical and Structure. +- **Route G, generic nesting.** + - `BHoMGroup[T]` (`Elements`), `Tree[T]` (`Name`, `Value`, `Children`), `DomainTree[T]` and `Graph[T]`. + - Every `BHoMObject` also has `Fragments`, `Tags` and `CustomData`. + - `Tree[Level|Floor|…]` gives project > building > storey > slab directly. + - `Tree_1` and `DomainTree_1` currently fail to import because of the generic self-reference bug in the phase 7 list. + +I found nothing else useful. `Planning_oM` has `ConstructionPhase` and `IPhase` (start and end times), but its other classes (`Issue`, `PullRequest`, `Repository`) are GitHub concepts. + +## Recommendation + +The BHoM oM doesn't model a project tree itself. Use `Building`, `Level`, `Floor`, `Wall`, `Room` and `Opening` for the nodes. For the tree, either add a thin new layer or use `Tree[T]` or `BHoMGroup[T]` once the generic bug is fixed. + +If you want a concrete skeleton (a `Project`, `Building` and `Storey` mapped onto these classes with `Level` linked to `Floor`), I can draft it. It would need either new C# oM classes or a Python-only layer, and that choice is yours. \ No newline at end of file diff --git a/docs/Testing-Guide.md b/docs/Testing-Guide.md new file mode 100644 index 0000000..155351d --- /dev/null +++ b/docs/Testing-Guide.md @@ -0,0 +1,216 @@ +# Testing guide: reading `.ci/unit-tests` + +This explains how the tests in [`.ci/unit-tests`](../.ci/unit-tests) are organised and how Python testing (pytest) works, for +someone who knows NUnit but has not written Python tests. For the Python language basics used in the code, see +[Python-Packages-Primer.md](Python-Packages-Primer.md). + +## 1. The big picture + +The project has two converters, so the tests have two halves that feed each other: + +``` + NUnit (C#) pytest (Python) + ---------- --------------- + 1. generate Python from C# types ----writes-----> _output/fixtures/bhom_schema + (fixture assemblies + real BHoM) _output/spike/bhom_schema + | + test_fixtures, test_spike: import it, use it, run Pyright on it + test_alignment: compare it with the C# manifest + | + 2. py2cs drafts C# from that Python --writes---> _output/roundtrip/*/*.cs + 3. NUnit compiles the C# with Roslyn -----------> draft-manifest.json + | + test_alignment: draft-manifest must equal the original +``` + +So the C# tests are not only checks; they **produce the input** the Python tests read. That is why the order matters and why +`run-all.ps1` exists. + +## 2. What is in the folder + +| Path | What it is | +|---|---| +| `run-all.ps1` | Runs every stage in the right order. Use this unless you are working on one part. | +| `csharp/PythonSchemaGeneration.Tests/` | The NUnit tests (44 tests). | +| (not in this repository) | The fake oM assemblies `FixtureAlpha_oM` and `FixtureBeta_oM` live in the [CSharpSchema_Test](https://github.com/BHoM/CSharpSchema_Test) repository, which must be cloned next to this one (or set `CSharpSchemaTestRoot`). See section 3. | +| `python/` | The pytest tests (about 73 test cases) in four folders: `test_py2cs`, `test_fixtures`, `test_spike`, `test_alignment`. | +| `python/support.py` | Shared helpers for the pytest folders. | +| `golden/` | Committed "expected output" files. See section 6. | +| `github-fixtures/repo-listing.json` | A recorded list of which repository owns which assembly folder, so tests need no network. | +| `_output/` | Everything the tests generate. Git-ignored; safe to delete, it is rebuilt by the next run. | + +## 3. Two kinds of test input + +| Tier | Input | Needs | Where | +|---|---|---|---| +| **Fixtures** | `FixtureAlpha_oM` and `FixtureBeta_oM` in the CSharpSchema_Test repository, small assemblies written for the tests. Each type is there to provoke one situation: an enum member named `None`, an interface chain, a generic, a same-named type in two assemblies, a reference cycle, and so on. | Only `BHoM.dll` | Run anywhere | +| **Real types** | The actual BHoM oM, for example `ProjectIdentification`, `Line` and `Bar`. | The BHoM assemblies installed in `%ProgramData%\BHoM\Assemblies` | Ignored locally if missing; fail in CI | + +The fixtures exist so the logic can be tested precisely and without depending on whatever the BHoM `develop` branch looks like +today. The real-type tests then prove it works on the real thing. + +## 4. NUnit side (`csharp/PythonSchemaGeneration.Tests`) + +You know this style already. Where to look: + +- `TestSupport.cs`: helpers. `TestPaths` finds the repo root, `FixtureOutput` generates the fixture package once and shares it, and + `RealAssemblies` checks the real assemblies exist (`RequireOrSkip` ignores the test locally, fails it in CI). +- `FixtureExtractionTests.cs`: what the generator produces for each fixture type (bases, defaults, type mapping, generics, + metadata, paths, cycles). Most tests assert that the generated Python text contains an expected line. +- `RealTypeTests.cs`: the same ideas against the real spike types. Marked `[Category("RealTypes")]`. +- `GoldenAndWorkflowTests.cs`: golden-file comparisons, deterministic output, clean-up of stale files, and a guard that the + generator does not depend on the JSON schema repo. +- `RoundTripTests.cs`: compiles the drafted C# with Roslyn and writes `draft-manifest.json`. Marked `[Category("RoundTrip")]`. +- `DocumentationTests.cs`: every C# method and constructor under `.ci` has a `[Description]` saying what it does and where it fits in the + workflow. The Python counterpart is `test_py2cs/test_documentation.py`, which checks every function for a docstring. Golden expected-output + files are skipped. A new method fails these tests until it is described. + +Run only some of them with a filter: + +```powershell +dotnet test .ci/generation/PythonSchemaGeneration.sln -c Release --filter "Category=RealTypes" +``` + +## 5. pytest in ten minutes + +pytest finds tests by **naming convention**, there are no attributes: + +- files named `test_*.py` +- functions named `test_*` inside them + +```python +def test_diff_property_added(tmp_path): + ... + assert diff.changes[0].kind == "modified" +``` + +| NUnit | pytest | +|---|---| +| `[Test]` | any function named `test_...` | +| `Assert.That(a, Is.EqualTo(b))` | `assert a == b` | +| `Assert.Throws(...)` | `with pytest.raises(T):` | +| `[TestCase(1)] [TestCase(2)]` | `@pytest.mark.parametrize("name", [1, 2])`, one test case per value | +| `[SetUp]`, `[OneTimeSetUp]` | **fixtures** (below) | +| `Assert.Ignore(...)` | `pytest.skip("reason")` | +| `[Category("X")]` | `@pytest.mark.x` markers | +| `[TestCaseSource]` | `parametrize` with a list | + +**Plain `assert`.** pytest rewrites it so that a failure shows both sides, so you never write `Assert.AreEqual`. A message can +follow a comma: `assert problems == [], "\n".join(problems)`. + +**Fixtures.** A fixture is a function marked `@pytest.fixture` that provides something a test needs. A test asks for it simply by +naming it as a parameter; pytest calls the fixture and passes the result in. Built-in ones you will see: + +- `tmp_path`: a fresh empty temporary folder (a `Path`) for each test. Most `test_py2cs` tests build a small fake package or C# tree + in it and never touch the real repository. +- `capsys`: captures what the code prints, so a test can check console output. + +Custom fixtures live in `conftest.py` files. pytest loads `conftest.py` automatically for the tests in the same folder. For example +`test_fixtures/conftest.py`: + +```python +@pytest.fixture(scope="session", autouse=True) +def fixtures_package(): + package = require_output("fixtures") + attach(package) + return package +``` + +- `scope="session"`: run once for the whole test run, not once per test. +- `autouse=True`: apply to every test in the folder without being requested. It makes the generated package importable before any + test runs, and the value it returns can still be requested by name, which is how `test_every_module_imports...(fixtures_package)` + receives the folder. + +**Parametrize.** `test_manifest_alignment.py` runs one function twice, once for `"fixtures"` and once for `"spike"`; the report +lists them as two test cases, which is why the case count is higher than the number of functions. + +**Skip and fail.** `support.require_output` returns the generated folder, or skips the test locally if `dotnet test` has not run +yet, or fails it when the `CI` environment variable is `true`. That is the Python twin of `RealAssemblies.RequireOrSkip`. + +## 6. Golden files + +A golden file is a committed copy of expected output. The test generates the output, compares it with the file, and fails on any +difference. Use `golden/` when exact text matters: + +- `golden/fixtures/`: the Python generated for the fixture types, and the C# manifest. +- `golden/spike/`: a small, stable real type (`GradientCenteringOptions`). +- `golden/py2cs/`: the C# that `py2cs` writes for small example classes. + +When you change the generator on purpose, regenerate them with the environment variable `UPDATE_GOLDEN=1`, then **read the diff** +in git before committing. A golden file you did not review proves nothing. + +```powershell +$env:UPDATE_GOLDEN = "1" +dotnet test .ci/generation/PythonSchemaGeneration.sln -c Release +Remove-Item Env:UPDATE_GOLDEN +``` + +The same variable works for the Python golden tests (`test_py2cs`). + +## 7. The four pytest folders + +They are run as **separate pytest sessions**, because the fixture and spike packages both contain folders such as `BHoM` and +cannot be imported side by side in one process. + +| Folder | Needs | What it checks | +|---|---|---| +| `test_py2cs/` | nothing generated | The Python to C# converter on tiny hand-built packages: reading, diff, C# emission against golden files, finding and placing files, repo resolution, the planned output, the command line. `helpers.py` has the builders (`module(...)` writes source in the generated style, `package(...)` writes it to disk). Two tests use the real `C:\Github\dKoP_Toolkit` clone and are skipped if it is missing. | +| `test_fixtures/` | `_output/fixtures` | The generated fixture Python **behaves** correctly: every module imports, Pyright strict is clean, interface fields accept and reject the right objects, defaults are not shared, enums, generics, cycles, same-named classes. | +| `test_spike/` | `_output/spike` | The same ideas on the real spike types. `test_json.py` also checks the JSON: round trips, a JSON Schema for every class, and (with a read-only clone of BHoM_JSONSchema next to this repository, or at `BHOM_JSONSCHEMA_ROOT`) that what Python writes validates against the published schemas | +| `test_support/` | nothing generated | The hand-written support package alone, with small stand-in classes: `_t`, `_bhomVersion`, interface fields (`Polymorphic`), enums by name, and their JSON Schema | +| `test_alignment/` | both outputs | Two structural checks: the Python read back by `py2cs` equals what C# reflection reported, and the drafted-then-compiled C# equals the original. | + +`test_fixtures` and `test_spike` also run **Pyright** on the generated package by launching it as a separate process +(`support.run_pyright`). That is a test of the generated code's types, not of `py2cs`. + +## 8. Running things + +Everything, in the right order: + +```powershell +.ci\unit-tests\run-all.ps1 +``` + +Its stages are: build; NUnit (which generates `_output`); `py2cs drafts`; NUnit round trip; the four pytest folders; Pyright. + +One part, from the repository root with the virtual environment's Python: + +```powershell +.venv\Scripts\python -m pytest .ci/unit-tests/python/test_py2cs -q +``` + +Useful options: + +| Command | Meaning | +|---|---| +| `... -k project` | only tests whose name contains `project` | +| `... -x` | stop at the first failure | +| `... -vv` | show every test name and fuller diffs | +| `... path/to/test_file.py::test_name` | run exactly one test | +| `... -s` | show `print` output while running | + +If a pytest folder reports `skipped`, the message says what is missing; most often you have not run `dotnet test` yet. Locally, +real-type tests are ignored when a BHoM assembly is missing, and the preflight at the start of `run-all.ps1` lists which. + +## 9. How to read one test, step by step + +Take `test_diff_property_added` in `python/test_py2cs/test_reader_and_diff.py`: + +1. It asks for `tmp_path`, an empty folder. +2. It builds a base package and a head package inside it (`demo_package(...)`); the head has one extra field. +3. It reads both with `read_package` and calls `diff_manifests`. +4. It asserts the result is one change, `modified`, saying `property added: string ProjectNumber`. + +Almost every test follows **arrange, act, assert**. When a test fails, the report prints the values on both sides of the `assert`. + +## 10. Where to add a test + +| You changed | Add the test in | +|---|---| +| How a C# type becomes Python text | `FixtureExtractionTests.cs` (add a type to `FixtureAlpha_oM` in CSharpSchema_Test if you need a new situation, then regenerate the golden files) | +| Behaviour of the generated Python classes | `test_fixtures/test_generated_fixtures.py` | +| Something specific to a real BHoM type | `RealTypeTests.cs` and `test_spike/` | +| The Python to C# converter | `test_py2cs/` | +| The shape of the manifest shared by both sides | `test_alignment/` | + +After a change, run `run-all.ps1`. Per the project rules it must finish green, including the build, before the work is done. diff --git a/docs/Workflows.md b/docs/Workflows.md new file mode 100644 index 0000000..304f321 --- /dev/null +++ b/docs/Workflows.md @@ -0,0 +1,157 @@ +# GitHub workflows + +Four workflows run the loop between this repository and the repository that owns the C# classes, and make releases. For now that repository is +[BHoM/CSharpSchema_Test](https://github.com/BHoM/CSharpSchema_Test), a repository made for testing. **No other repository is ever +written to.** BHoM, BHoM_Engine and JSONSchema_Toolkit are only cloned and built (read only) because the generator needs them. + +| Workflow | When | What it does | +|---|---|---| +| [python-schema-generation.yml](../.github/workflows/python-schema-generation.yml) | Every Saturday at 4 AM UTC, and by hand | Clones and builds the C# repositories, generates `src/bhom_schema` from the C#, checks it, and commits it straight to `develop`. Not a pull request. If a check fails, nothing is committed | +| [pr-checks.yml](../.github/workflows/pr-checks.yml) | Every pull request to `develop` | Builds the same repositories (`Included-repositories.txt`), clones BHoM_JSONSchema read only as the test oracle for the JSON, then runs `run-all.ps1` (build, NUnit, pytest, Pyright). For a PR that changes `src/bhom_schema` it also runs `py2cs check` and `py2cs align` | +| [linked-csharp-pr.yml](../.github/workflows/linked-csharp-pr.yml) | A pull request that changes `src/bhom_schema`, every hour, and by hand | Raises, updates or closes a linked **draft** pull request in the C# repository, and sets the commit status `linked C# pull request` on the Python PR | +| [release.yml](../.github/workflows/release.yml) | By hand only (dry run by default) | Regenerates with `--release X.Y`, checks, builds the wheel and the release notes; when not a dry run, pushes branch `vX.Y` and tag `vX.Y.0`, creates the GitHub Release and publishes to TestPyPI. See "Making a release" | + +## How they fit together + +``` + C# repositories (BHoM, BHoM_Engine, JSONSchema_Toolkit: read only; CSharpSchema_Test: the repository that owns the converted classes) + │ ▲ + │ cloned + built │ draft PR "python-schema/pr-" + ▼ │ +┌──────────────────────────────┐ commit straight to develop ┌───┴───────────────────────┐ +│ python-schema-generation.yml │ ─────────────────────────────► │ linked-csharp-pr.yml │ +│ Saturday 4 AM UTC, or manual │ │ (see below) │ +│ C# ──► src/bhom_schema │ └───────────────────────────┘ +└──────────────┬───────────────┘ + │ develop (regenerated every week) + ▼ + a person changes src/bhom_schema on a branch and opens a pull request to develop + │ + ├────────────────────────────────────────────┐ + ▼ ▼ +┌──────────────────────────────┐ ┌─────────────────────────────────────────────────┐ +│ pr-checks.yml │ │ linked-csharp-pr.yml │ +│ on every PR to develop │ │ on a PR that changes src/bhom_schema │ +│ build, NUnit, pytest, │ │ │ +│ Pyright; py2cs check + align │ │ prepare ─► repos ─► raise ─► status │ +│ (writes nothing) │ │ (draft C# PR, and the commit │ +└──────────────────────────────┘ │ status on the Python PR) │ + │ PR closed: prepare ─► repos ─► close │ + │ every hour: sweep (refreshes the status of │ + │ every open Python PR) │ + └─────────────────────┬───────────────────────────┘ + │ the C# draft is reviewed and merged in C# + ▼ + next weekly generation: the C# is the source of truth, + so src/bhom_schema ends up matching the merged C# + + End of a milestone, by hand: +┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ release.yml (input X.Y, dry run by default) │ +│ job build: clone + build ─► generate --release X.Y ─► verify + Pyright ─► wheel + notes │ +│ not a dry run: branch vX.Y, tag vX.Y.0, GitHub Release (wheel + notes) │ +│ job publish-testpypi (not a dry run): the wheel ─► TestPyPI, by trusted publishing │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +Order in one line: **generation** (weekly) → a Python **pull request** → **pr-checks** and **linked-csharp-pr** run side by side → the C# +draft is merged → the next **generation** brings the Python back in line → **release** (by hand) freezes it as `vX.Y`. + +## What each one reads and writes + +| | Reads | Writes | +|---|---|---| +| Generation | `Included-repositories.txt` (what to build), `Converted-repositories.txt` (what to convert), `.ci/generation/warnings-baseline.txt` | `src/bhom_schema` on `develop` | +| PR checks | the same | nothing | +| Linked C# PR | `Converted-repositories.txt` | a branch `python-schema/pr-` and a draft PR in the C# repository, and one comment on the Python PR | + +`Converted-repositories.txt` is also the list of repositories a C# pull request may be raised in. A Python class names its own +repository (`__bhom__["repo"]`), so without that list a pull request could aim at any repository the GitHub App can reach. To add +a repository to the loop, add it to both text files (`Included-repositories.txt` so it is built, `Converted-repositories.txt` so it +is converted), and install the App on it. + +## One-time setup + +Someone with admin rights on the organisation does this once. + +1. **A GitHub App** that can push a branch and open pull requests. Permissions: *Contents: read and write*, *Pull requests: read and + write*, *Metadata: read*. Install it on **BHoM_PythonSchema and CSharpSchema_Test only**. A repository outside the installation + cannot get a token, so the App cannot write to it. +2. **Credentials** in this repository (Settings, Secrets and variables, Actions): variable `BHOM_APP_ID` and secret + `BHOM_APP_PRIVATE_KEY` (the whole `.pem` text). If the App already exists for other workflows, reuse its names. +3. **`develop` must accept the weekly commit.** The generation pushes to `develop` with the workflow's own token. If `develop` is + protected and requires pull requests, allow `github-actions[bot]` (or the App) to bypass that rule. +4. **Actions** are enabled, and the default workflow permissions can stay read-only: each workflow asks for what it needs. + +## Running things + +The weekly generation can be run by hand from the Actions tab (*Run workflow*). With `dry_run` ticked it generates and checks but +does not commit; the generation report is attached to the run. + +The linked pull request can be tried without writing anything: + +1. **On a laptop.** In a scratch copy of `src/bhom_schema`, change a class, then (with the C# repository cloned next to this one): + + ```powershell + $env:PYTHONPATH = ".ci/generation" + $env:PY2CS_PATCHER = ".ci/generation/PythonSchemaGeneration.Patcher/bin/Release/net8.0/PythonSchemaGeneration.Patcher.dll" + .venv\Scripts\python -m py2cs plan --base-root /bhom_schema --head-root /bhom_schema --clones-parent C:\Github --allowed-repos Converted-repositories.txt --only-repo BHoM/CSharpSchema_Test --python-pr BHoM/BHoM_PythonSchema#7 --out plan + .venv\Scripts\python -m py2cs pr --plan-dir plan --repo BHoM/CSharpSchema_Test --python-repo BHoM/BHoM_PythonSchema --python-pr 7 --dry-run + ``` + + Without a token the dry run is offline: it prints the branch, the files, the PR title and the labels, and never calls GitHub. It + does not write into the clone. +2. **On GitHub, dry run.** Open a Python PR, then run *Linked C# pull request* with its number and `dry_run` ticked (the default). It + makes the App token for `CSharpSchema_Test` only, which proves the installation and permissions, and prints what it would do. +3. **On GitHub, live.** Run it again with `dry_run` unticked. It pushes `python-schema/pr-` to CSharpSchema_Test and opens a draft + PR. Run it once more to see that it updates the same branch and PR. When the Python PR is closed without merging, the workflow + closes the draft with a comment; delete the branch by hand. + +## Making a release + +Run *Release* from the Actions tab at the end of a milestone, with the BHoM version as `Major.Minor` (for example `10.0`). It is a dry run +by default: it builds the C# repositories, regenerates with `--release 10.0`, checks the result, builds the wheel and writes the release notes, +and attaches them to the run. The generator stops the run when `10.0` is not the assembly version it read from `BHoM.dll` (or from another assembly when that is missing). Untick `dry_run` to: + +1. commit the regenerated classes to a new branch `v10.0` and tag `v10.0.0` (a release is never made twice; an existing branch or tag stops the run); +2. create a GitHub Release with the wheel and the release notes (`python -m py2cs notes`: the breaking changes first, then the added, removed and changed classes); +3. publish the wheel to TestPyPI. + +TestPyPI is reached with trusted publishing, so no password or token is stored. Set it up once, by an administrator: + +- On test.pypi.org, add a pending publisher for the project `bhom-schema` with this repository, the workflow `release.yml` and the environment `testpypi`. +- In the repository settings, create the environment `testpypi` (it can require a reviewer). + +Real PyPI is added the same way when BHoM/BHoM is in scope. The branch `v10.0` is left alone afterwards. + +## The linked pull request status + +Nothing tells this repository when a C# pull request is merged, so `linked-csharp-pr.yml` also sets a commit status on the Python PR +and refreshes it every hour: + +| Status | Meaning | +|---|---| +| pending | the C# draft is open: waiting for it to be merged | +| success | the C# pull request is merged (or the Python change needs no C# change) | +| failure | the C# pull request was closed without merging: change the Python, or close it | + +To stop a Python PR being merged before its C# PR, make the status `linked C# pull request` a **required check** on `develop` +(Settings, Branches). The hourly run only reads the C# repositories with this repository's own token, so they must be public. A +Python PR with no linked draft gets no status from the hourly run. + +## The alignment check + +`pr-checks.yml` runs `py2cs align` for a PR that changes `src/bhom_schema`. It generates the Python for the changed classes from +the C# `develop` branches it has just built, and compares it with the PR. So it fails until the linked C# PR is merged, which is the +rule: a Python PR is a proposal and is only merged after its C# PR. After the C# PR is merged, re-run the workflow. + +## Safety + +- The App token is made for one repository at a time and passed only to the step that needs it, through the environment. It is never + printed, and error messages never contain it. +- The tooling that holds the token (`py2cs` and the patcher) is taken from the **base** branch, so a pull request cannot change the + code that runs with the token. +- A pull request from a fork gets no linked pull request, because forks never receive the secrets. +- The linked-PR workflow has no `push` trigger, so the weekly generation commit can never start it. +- `.ci/generation/warnings-baseline.txt` lists the known kinds of generator warning. A new kind fails the weekly run, which means a + change in BHoM that the generator does not handle yet. Review it, then update the baseline with `--write-baseline`. diff --git a/docs/audit-1/Fix-Plan.md b/docs/audit-1/Fix-Plan.md new file mode 100644 index 0000000..66f8b6b --- /dev/null +++ b/docs/audit-1/Fix-Plan.md @@ -0,0 +1,644 @@ +# Audit 1: plan to fix the review findings + +Date: 2026-10-26. Status: **Steps 1 to 3 done**; Step 4 written and tested, but not run on GitHub yet (see "What was done in Step 4"); Step 5 done (see "What was done in Step 5"); Step 6 done, but the release workflow has not run on GitHub yet (see "What was done in Step 6"); Steps 7 and 8 not started. + +The solution was reviewed against the Option 2 system design: the C# oM is the "source of truth", meaning that when the +Python and the C# disagree, the C# wins. The review found eight issues (**Issue 1** to **Issue 8**). This plan fixes them in +eight **steps**. The steps are numbered in the order to do them, so just follow Step 1, Step 2, and so on. + +Each step is written for someone new to Python. It says what is wrong in plain words, shows a small example, lists what we +will do, and says how we will know it works. + +--- + +## Before you start: words used in this plan + +| Word | Plain meaning | +|---|---| +| **oM** | The BHoM "object model": the C# classes that describe things like `Bar`, `Line` or `Room`. Data only, no logic. | +| **Generator** (C# → Python converter) | Our C# program in `.ci/generation/PythonSchemaGeneration`. It looks inside the compiled BHoM `.dll` files and writes one Python file per C# class. | +| **py2cs** (Python → C# converter) | Our Python program in `.ci/generation/py2cs`. It reads the Python files and drafts the matching C# code. | +| **Pydantic** | A popular Python library for data classes. A Pydantic class lists its fields with types, checks values when you create an object, and can turn an object into JSON and back. Our generated classes are Pydantic classes. | +| **Field** | One piece of data in a class, the Python version of a C# property. `ProjectName: str = ''` is a field named `ProjectName`, of type `str` (text), with default value `''` (empty text). | +| **Type annotation** | The `: str` part after a field name. It tells Python and Pydantic what kind of value the field holds. | +| **Default** | The value a field gets when you don't give one. A field with no default is **required**: you must give it when creating the object. | +| **`__bhom__` marker** | A small dictionary at the top of every generated class that remembers where it came from: the C# full name, assembly, namespace and GitHub repository. py2cs needs it to know which C# repository and file to change. | +| **Interface** | In C#, a contract such as `ICurve` that many classes fulfil (`Line`, `Arc`, `Polyline`...). In our Python it becomes an empty base class. | +| **AST** | "Abstract syntax tree". py2cs reads Python files as text and turns them into a tree of code pieces, without running them. That is safe, but it only understands the shapes of code it was taught. | +| **Roslyn** | Microsoft's C# compiler as a library. It can read a `.cs` file into a tree, change one piece, and write it back with everything else untouched. | +| **Manifest** | A JSON file listing every type with its fields, written by the generator. Used to compare the C# view and the Python view. | +| **PR** (pull request) | A proposed change on GitHub that people review before it is merged. | +| **Linked PRs** | The two PRs in the system design: one in this repository (Python), one in the C# repository (C#), raised together. | +| **CI** / **workflow** | Scripts GitHub runs for us automatically (on a schedule, or when a PR changes). They live in `.github/workflows/*.yml`. | +| **pytest** / **NUnit** | The test runners for Python and C#. | +| **Pyright** | A tool that reads Python code and reports type mistakes, like the C# compiler would. | +| **JSON** | The text format used to save objects to files or send them over the web, for example `{"Name": "Bar 1", "Length": 3.2}`. | +| **Wheel** | The file you install a Python package from (`pip install ...`). | +| **Build and test gate** | `dotnet build .ci/generation/PythonSchemaGeneration.sln -c Release` with no errors, then `.ci/unit-tests/run-all.ps1` fully green. **Every step ends with this gate**; a step is only done when it passes. | + +--- + +## Decisions taken (2026-10-26) + +| Topic | Decision | +|---|---| +| How py2cs changes an existing C# file (Step 2) | **Patch member by member.** This reverses the old "suggested full-file replacement" decision in CODING-PLAN section 9. Whole files are written only for brand-new types. | +| Where the input assemblies come from (Step 4) | **Our own build, on GitHub** (revised 2026-10-29; was: share the BHoM_JSONSchema weekly build). Our workflows clone BHoM, BHoM_Engine, JSONSchema_Toolkit and CSharpSchema_Test from GitHub and build them. BHoM_JSONSchema is not changed until our system is proven, and with far fewer projects converted to Python than to JSON, the two DLL sets may stay separate for good. | +| Which assemblies go into the package (Step 6) | **For now, only `BHoM/CSharpSchema_Test`; BHoM/BHoM later** (by adding it to `Converted-repositories.txt` and `Included-repositories.txt`). Toolkits such as `dKoP_Toolkit` stay out. | +| BHoM JSON compatibility (Step 5) | **Approved** (2026-10-29) and done: follow the rules of BHoM_JSONSchema (`_t`, interface implementers as the allowed `_t` values, enums as names). The old 5A/5B split is dropped. Reasons are in Step 5. | + +What the scope decision changes in the other steps: +- Step 1: a brand-new Python class must go into an existing assembly of BHoM/BHoM; there is no `Python_Toolkit` fallback for now. +- Step 4: the generator only reads assemblies whose repository is `BHoM/BHoM`, even though the shared build contains more. +- Step 4: the rehearsal can no longer use `dKoP_Toolkit`, because dKoP is outside the package. A new target must be chosen. + +--- + +## The order at a glance + +| Step | Fixes | In one sentence | Why at this point | +|---|---|---|---| +| 1 (done) | Issue 2 | Make py2cs understand Python written by people, not only by the generator | Everything py2cs drafts depends on reading the Python correctly | +| 2 (done) | Issue 1 | Change only the lines that need changing in an existing C# file | So the drafted C# is something BHoM reviewers will accept | +| 3 (done) | Issue 4 | Agree how the Python PR and the C# PR stay in step, and warn about breaking changes | The rules must exist before they are automated | +| 4 (code done) | Issue 5 | Automate everything on GitHub, using the JSON repository's build | Once the loop is safe, make it run by itself | +| 5 (done) | Issue 3 | Let objects with interface-typed fields be saved to JSON and loaded back | Makes the package useful to other Python projects (decision needed) | +| 6 (code done) | Issue 6 | The scope (test repository for now), a version number from BHoM, release notes, publishing | Needed before anyone installs it | +| 7 | Issue 7 | Five small generator fixes | Low risk; can be picked up at any time | +| 8 | Issue 8 | Tidy the documentation | Last, so the docs describe the finished state | + +--- + +## Step 1 (Issue 2): make py2cs understand Python written by people + +### What's wrong, in plain words + +In the system design, a developer edits the Python classes in a PR, and py2cs turns that edit into a C# PR. But py2cs was +only taught to read the exact style the generator writes. Normal Python written by a person confuses it, and some of the +failures are silent. + +We reproduced four problems: + +1. **A new class without the `__bhom__` marker is ignored without any message.** No C# PR would be raised, and nobody would know why. + + ```python + class ProjectStage(SchemaModel, IdKoPObject): # no __bhom__ dictionary + Stage: int = 0 # py2cs: "0 changes" + ``` + +2. **A very common way of writing a default is misread.** In Pydantic, `Field(0, description="...")` means "default 0, with + a description". py2cs only understands `Field(default=0, ...)`, so it thinks the field has *no* default. Its C# draft + then made the whole class read-only, with a new constructor, which would break every existing `new ProjectIdentification()` + in BHoM code. + +3. **Other common type styles crash it.** `Optional[int]` ("an int or nothing") stops the whole run with a Python error + message (a "traceback") instead of a clear explanation. + +4. **Only the first class in a file is read.** A second class in the same file is ignored without a message. + +### What we will do + +1.1 **Write down the rules for editing the Python** in the README, with a template file to copy. For example: one class per +file; the file goes in `src/bhom_schema///.py`; how to write a default and a +description; how to say "this is a C# `long`, not an `int`". + +1.2 **Teach py2cs the common styles** so these all work: + +```python +Stage: Optional[int] = None # same as int | None = None +Names: List[str] = Field(default_factory=list) # same as list[str] +Stage: int = Field(0, description="RIBA stage") # positional default +Stage: int = pydantic.Field(default=0) # Field called through the module name +``` + +1.3 **Fill in `__bhom__` automatically when it is missing**, instead of skipping the class. py2cs can work it out from where +the file is: the folder gives the assembly, and the other classes in the same folder give the namespace and repository. +The PR report says these values were worked out, so a reviewer can check them. A class placed in a folder that is not an +existing BHoM/BHoM assembly gets a clear error (see "Decisions taken"). + +1.4 **Collect problems instead of crashing.** py2cs reads every file, gathers all the problems (file, line, message), prints +them together and then stops. For example: + +``` +src/bhom_schema/Structure_oM/Elements/Bar.py:42: unsupported type 'Sequence[int]'; use list[int] +src/bhom_schema/Geometry_oM/Line.py: two classes in one file; move 'LineHelper' to its own file +``` + +1.5 **Never turn a missing default into a read-only C# class.** A new field without a default becomes a normal C# property +(`{ get; set; }`) and the report notes it. Read-only properties and a constructor are only drafted when the C# class is +already built that way. + +1.6 **Add a checking command**, `python -m py2cs check --root src/bhom_schema`, that runs all of the above without drafting +anything. It also warns when a new class or field has no description, because BHoM requires a `[Description]` on every oM +class and property. Step 4 runs it on every PR. + +### How we'll know it works + +- New tests in `.ci/unit-tests/python/test_py2cs`, one "before and after" pair of Python files for each style in 1.2, each + with the expected C#. +- The four reproduced problems now give correct C# or a clear message. +- The build and test gate passes. + +--- + +## Step 2 (Issue 1): change only the lines that need changing in an existing C# file + +### What's wrong, in plain words + +When a Python class changes, py2cs currently rewrites the **whole** C# file from what it knows. But the Python doesn't know +everything that is in the C# file, so the rewrite loses things and reformats others. + +We tested this by adding one field (`Mark`) to the Python `Bar`. The proposed `Bar.cs`: +- lost both `[DocumentationURL(...)]` attributes (links to the documentation); +- lost the interface `IElement1D` from the class line and reordered the others; +- joined long descriptions that were split over several lines into one long line; +- changed `= null` to nothing and `= 0` to `= 0.0`. + +One new line of Python became about 40 changed lines of C#. A BHoM reviewer would rightly reject that. + +### What we will do + +2.1 **Record the decision** (already taken): existing C# files are patched member by member. Update CODING-PLAN section 9. + +2.2 **Add a small C# tool**, `PythonSchemaGeneration.Patcher`, to the generation solution. It uses Roslyn (already used in our +tests) to open a `.cs` file, change only the listed members, and save it. Everything else, including comments, blank lines +and attributes it doesn't know about, stays exactly as it was. + +2.3 **Give the tool a precise list of changes** instead of a new file. py2cs already works out what changed (the "diff"); it +will save it as a list like this: + +```json +[ + { "change": "property added", "name": "Mark", "type": "string", "default": "\"\"", "after": "SectionProperty" }, + { "change": "description changed", "name": "OrientationAngle", "description": "..." } +] +``` + +For the `Bar` example, the result must contain **only** the new `Mark` property and its attributes. + +2.4 **Treat equal things as equal**, so nothing is changed for no reason: `= null` and no value on a class-typed property are +the same; `0` and `0.0` are the same; an interface already implied by another one doesn't need to be added. + +2.5 **Wire it in**: `py2cs plan` builds the change list and calls the tool for existing files; brand-new types still get a +whole new file. Anything the tool could not translate is listed in the PR description, not left as a comment in the code. + +### How we'll know it works + +- NUnit tests on copies of real files (`Bar.cs`, `ProjectIdentification.cs`, a file with several classes, a file with + `#region` blocks). Adding one property changes only the added lines. +- An empty change list gives back the file exactly as it was, byte for byte. +- The `Bar` test above gives a small, clean diff. +- The build and test gate passes. + +--- + +## What was done in Steps 1 and 2 + +- **Step 1:** `py2cs/reader.py` accepts the common styles (1.2), works out a missing `__bhom__` marker (1.3) and collects problems + per file (1.4). New fields without a default become settable properties (1.5). `python -m py2cs check` is in `py2cs/check.py` (1.6). + The rules are in the README, with a class to copy in `docs/templates/NewClass.py` (1.1). Tests: `test_py2cs/test_authoring.py`. +- **Step 2:** the Roslyn patcher is `.ci/generation/PythonSchemaGeneration.Patcher`, and py2cs builds its change list in + `py2cs/patch.py`. `py2cs plan` patches existing files and writes whole files only for new types. CODING-PLAN section 9 records + the new decision. Tests: `PatcherTests.cs` (NUnit, on copies of the real `Bar.cs` and `ProjectIdentification.cs` in + `golden/patcher`) and `test_py2cs/test_patch_changes.py`. +- **Differences from the plan:** + - The round-trip drafts (`py2cs drafts`) still turn fields without a default into get-only properties set by a constructor, + because those come from immutable C# types. + - A type that is new in Python but already declared in C# is reported as a problem instead of being patched. + - In an enum whose members have no explicit values, new members always go at the end, so the other members keep their numbers. +- **Result:** the `Bar` example from the review is now a 3-line C# diff. `run-all.ps1` passes: 65 NUnit tests, 88 pytest tests, + Pyright 0 errors. + +--- + +## What was done in Step 3 + +- **3.1 rules:** README section "Life of a Python change", and a checklist for Python schema changes in + `.github/PULL_REQUEST_TEMPLATE.md`. +- **3.2 alignment check:** `python -m py2cs align` (`py2cs/align.py`). It diffs base against head, runs the generator for the + changed classes over a folder of built oM dlls (`--assemblies`), reads the result back and compares it with the PR using + `compare.py`. Exit code 0 when they match, 1 with the differences listed (first value from the C#, second from the PR), 2 when + it could not run. `--generated-root` takes a package that was already generated. Building the merged C# is left to the caller: + the Step 4 workflow does it. +- **3.3 breaking changes:** `diff.py` lists them per type (`breaking_changes`, plus a rename guess when exactly one removed and + one added property share a type). `py2cs plan` puts them in a "Breaking changes" section of the report and writes the + `breaking-change` label to `plan.json` (`labels`). Kind changes are also treated as breaking. Putting the label on the C# PR is + part of `pr.py` in Step 4. +- **3.4 loop guard:** first built (`py2cs/guard.py`), then removed on 2026-10-28 because it guarded against a pull request that + does not exist: the weekly generation commits directly. Nothing in the code opens a weekly generation PR. +- **Tests:** `test_py2cs/test_lifecycle.py`, and `test_alignment/test_align_with_generator.py`, which runs the real generator over + the fixture assemblies. + +--- + +## Step 3 (Issue 4): keep the Python PR and the C# PR in step, and warn about breaking changes + +### What's wrong, in plain words + +The system design raises two linked PRs: one for the Python, one for the C#. But nothing says what happens next. Some +situations that can go wrong today: + +- The Python PR is merged first, then the C# reviewers rename the property. Next week the generator rebuilds the Python from + the C#, so the Python silently changes again, and any Python code using the first name breaks. +- The C# PR is rejected. Next week the generator silently removes the property from the Python again. +- Someone renames or deletes a C# property. BHoM needs a "versioning" entry (in Versioning_Toolkit) for that, so old saved + files can still be opened. Nothing warns about it. + +### What we will do + +3.1 **Write down the rules** in the README and in this repository's PR template: +1. A Python PR is a **proposal**. It is never merged before its C# PR. +2. The C# PR is reviewed and merged in its own repository, as usual. +3. Before the Python PR can be merged, an automatic **alignment check** (3.2) must pass. Or simply close the Python PR and + let the next weekly generation bring in the same change; both give the same files. +4. If the C# PR is closed without merging, the Python PR is closed too, with a comment explaining why. + +3.2 **Add the alignment check**, `python -m py2cs align`. It asks: "if we generate the Python from the C# that was actually +merged, do we get the same Python as in this PR?" It builds the affected C# assembly, runs the generator for just the +changed classes, and compares the result with the PR using the comparison code we already have +(`py2cs/compare.py`). It passes only when they match, and otherwise lists the differences. + +3.3 **Spot breaking changes** and list them in a "Breaking changes" section of the C# PR description, with a +`breaking-change` label and a reminder to add a versioning entry. Breaking changes are: a property removed or renamed (seen +as one removed and one added with the same type), a property's type changed, an enum value removed or renumbered, a class +removed, or a base class or interface removed. Nothing is blocked automatically; the reviewer decides. + +3.4 ~~**Stop the loop going round forever.**~~ **Not needed (decided 2026-10-28).** The weekly generation writes the Python +straight from the C# and commits it to `develop`. It is not a pull request, so it can never raise a C# PR, and there is no loop +to stop. The only rule left is that `linked-csharp-pr.yml` triggers on `pull_request` and never on `push` (Step 4). + +### How we'll know it works + +- Tests for `align`: same Python (pass), a different default (fail), a missing property (fail). +- Tests that each kind of breaking change is spotted, including a rename. +- The build and test gate passes. + +--- + +## What was done in Step 4 + +Everything is written and tested here. It has **not been run on GitHub yet**: that needs the GitHub App and two settings (see +[Workflows.md](../Workflows.md)), and the first runs are yours to start. + +- **4.1 generator:** `--baseline-warnings ` and `--write-baseline `, a committed `.ci/generation/warnings-baseline.txt` + (two known kinds), exit code 1 when a type could not be converted or a warning kind is new, exit code 2 for a misspelt option + (`CommandLine.cs`, `WarningBaseline.cs`). +- **4.2 repository filter:** `--repos`, done earlier. +- **4.3 build:** `Included-repositories.txt` now lists BHoM, BHoM_Engine, JSONSchema_Toolkit and CSharpSchema_Test. + `.github/scripts/Clone-Repositories.ps1` clones them from GitHub (read only) and records each commit; + `Build-Repositories.ps1` builds them in dependency order with the JSON repo's `build.ps1`. Nothing comes from a laptop. +- **4.4 weekly generation:** `python-schema-generation.yml`. It generates only what `Converted-repositories.txt` lists + (CSharpSchema_Test), checks it (`py2cs verify`: every module imports and reads back as the C# manifest says; Pyright), and commits + straight to `develop` with the commit of every built repository in the message. +- **4.5 PR checks:** `pr-checks.yml` runs `run-all.ps1`, and `py2cs check` and `py2cs align` for a PR that changes `src/bhom_schema`. + The test project finds the CSharpSchema_Test clone through the `CSharpSchemaTestRoot` environment variable. +- **4.6 linked C# PR:** `linked-csharp-pr.yml`, `py2cs/pr.py` (raise, update, close), `py2cs/github.py` (a REST client and an + in-memory mock) and the commands `repos`, `plan --only-repo --allowed-repos`, `pr` and `close`. C# pull requests can only be raised in + the repositories of `Converted-repositories.txt`. +- **4.7 first generation:** `src/bhom_schema` holds the 27 CSharpSchema_Test classes and 5 BHoM base classes, generated on a laptop + last week. The first weekly run on GitHub should change nothing; if it does, it commits the difference. +- **4.8 rehearsal:** the local dry run is done (a new property on `Beam` becomes 3 lines in `Objects.cs`, and a new `Panel.cs`, with + the clone untouched). The workflow dry run and the live run are for you to start, in the order in [Workflows.md](../Workflows.md). +- **Tests:** `test_linked_pr.py` (branch names, a second run changing nothing, a changed PR updating the same PR, closing, dry runs, + the REST calls and that the token never appears), `test_workflows.py` (no `push` trigger, read-only permissions, one-repository + tokens, base-branch tooling, BHoM never written to), `test_verify.py` and `CommandLineAndBaselineTests.cs`. +- **Differences from the plan:** the linked-PR comment on the Python PR is updated in place instead of repeated; `align` is part of the + PR checks job, so that job stays red until the linked C# PR is merged. +- **Changes after review (2026-10-29):** + - The dKoP tests were dropped, so the PR checks build only `Included-repositories.txt` and no extra repositories are declared anywhere. + - `CommandLine.cs` moved from the Core library into the `PythonSchemaGeneration` console project; the tests reference that project. + - The linked-PR workflow sets a commit status on the Python PR (`status.py`, `py2cs status`): pending, success or failure from the + state of the linked C# PR. An hourly run refreshes it, because nothing tells this repository when a C# PR is merged. + +--- + +## Step 4 (Issue 5): automate everything on GitHub, using the JSON repository's build + +### What's wrong, in plain words + +Today everything runs by hand on one machine: +- There are no GitHub workflows yet, and no code to open PRs (`pr.py` doesn't exist). +- The real generated package has never been committed: `src/bhom_schema` only has the hand-written helper folder. +- The generator always reports success (exit code 0), even when a class failed to convert, so automation couldn't notice a problem. +- The generator reads whatever `.dll` files happen to be installed in `%ProgramData%\BHoM\Assemblies` on that machine, so two + people can get different results. + +### What we will do + +4.1 **Make the generator report failure properly**: +- Exit code 1 when any class could not be converted. +- A committed list of known warning kinds (`--baseline-warnings `). A new kind of warning fails the run, so a BHoM + change that needs a generator update is noticed. +- Complain about misspelt command-line options instead of ignoring them. + +4.2 **Add a repository filter** to the generator, `--repos BHoM/BHoM` (**done 2026-10-28**: implemented as `--repos`, first used with `BHoM/CSharpSchema_Test`; a repo run only cleans its own assembly folders), which keeps only assemblies whose description points at +that repository. This applies the scope decision while still reading the shared build (see Step 6). + +4.3 **Build the C# repositories ourselves** (revised 2026-10-29; the text below is the original idea, kept for later): +- The JSON repository's weekly job already downloads and builds the BHoM repositories. We add one step to it that uploads the + built `.dll` files as a workflow "artifact" (a file GitHub keeps for a few days). +- When their job finishes, our workflow starts, downloads that artifact and runs our generator on it. Their logic and ours + stay separate; only the build is shared. +- This needs a small change in the BHoM_JSONSchema repository, so its maintainers must agree. Until then, a stop-gap is to + build `BHoM`, `BHoM_Engine` and `JSONSchema_Toolkit` ourselves, which is small now that only BHoM/BHoM is in scope. +- The generation commit message lists the commit of every repository that was built, so anyone can reproduce it. + +4.4 **Weekly generation workflow** (`python-schema-generation.yml`): get the `.dll` files (4.3), run the generator into +`src/bhom_schema`, run the checks (every module imports, Pyright, the C# vs Python comparison), then commit the result straight +to `develop`. It is not a pull request: the C# is the source of truth, so the weekly run just rewrites the Python. If a check +fails, nothing is committed. If `develop` is protected, the GitHub App must be allowed to bypass the pull request rule. + +4.5 **PR checks workflow** (`pr-checks.yml`), on every PR: build, NUnit, pytest, Pyright, `py2cs check` (Step 1), and +`py2cs align` (Step 3) for PRs that change the Python classes. + +4.6 **Linked C# PR workflow** (`linked-csharp-pr.yml`) and `py2cs/pr.py`, as designed in CODING-PLAN section 8. In short: when +a Python PR changes, work out which C# repository owns each changed class, get a short-lived access token for that +repository only (from the existing GitHub App), push a branch `python-schema/pr-`, open a **draft** C# PR, and add links +both ways. Running it again updates the same PR rather than opening a new one. `pr.py` talks to GitHub through a small +interface, so tests can use a mock GitHub that never touches the network. + +4.7 **First real generation**: run the weekly workflow once and commit its result to `src/bhom_schema`, rather than generating +on a laptop. + +4.8 **Rehearsal**: a dry run first (prints what it would do, changes nothing), then one live draft PR, only with your explicit +go-ahead, cleaned up afterwards. **The target needs choosing again**: the old plan used `dKoP_Toolkit`, which is now out of +scope. **Decided: `BHoM/CSharpSchema_Test`**, a repository made for this. No BHoM repository is written to. + +### How we'll know it works + +- Tests: exit codes; a new warning kind fails; the repository filter keeps only BHoM/BHoM. +- Tests with the mock GitHub: branch names, re-running updates the same PR, closing behaviour. +- The three workflows run green on a test branch of this repository. +- The build and test gate passes. + +--- + +## What was done in Step 5 + +- **5.2 `_t` and `_bhomVersion`:** `SchemaModel` (hand-written, in `_support`) writes `_t` first on every object, reads it (optional, and refused when it names + another class), carries `_bhomVersion` (left out when empty), adds `_t` to the JSON Schema, and reads a FragmentSet written as `{"_t": "BH.oM.Base.FragmentSet", + "_v": [...]}` as a list. No generated field is added, so py2cs and the C# manifest are not affected. +- **5.3 implementers:** the generator writes `implementers` into the marker of every non-generic interface and abstract class: the generated, concrete, non-generic + classes that implement it, as C# name to module path. A run for some repositories lists only what it generates (`--repos`). +- **5.4 `Polymorphic[X]`** (`_support/polymorphic.py`): for fields typed with an interface or abstract class (also inside lists and optionals, and as a generic + bound). An object of the interface is accepted; JSON needs `_t`, which must be an implementer, and the class is imported only then and validates alone, so the + error names the real problem. Base classes are never wrapped. +- **5.5 JSON Schema:** the same shape as BHoM_JSONSchema (`_t` required, the implementers as its allowed values, an if/then rule per class). Descriptions are nested one + level only, because an implementer can hold the interface again; an interface met inside lists the allowed `_t` values. Delegate fields are left out of the schema. + `model_json_schema()` works for every generated class, including every BHoMObject. +- **5.6 enums:** `BHoMEnum` and `BHoMFlags` (`_support/enums.py`) are written by name (`None_` as `"None"`; flags as one name or the set flags joined by ", ") and + read from a name, a number, or BHoM's `System.Enum` object form. No [Flags] enum exists in BHoM/BHoM, so the flags form is only tested on the fixtures. +- **5.7 py2cs:** reads `Polymorphic[...]` and `SkipJsonSchema[...]` as the plain type, and the two new enum bases as enums. +- **Tests:** `test_support/test_json.py` (the support package alone), `test_fixtures` (JSON round trips on generated classes, a JSON Schema for every class), + `test_spike/test_json.py` (round trip of a real Bar, and the conformance tests), NUnit tests for the generator output. +- **Conformance** with the published schemas (read-only clone of BHoM_JSONSchema, only as the test oracle; skipped when it is not there, cloned by the PR checks + workflow): Point, Line, Line as an ICurve, a whole Bar, and an enum in both forms validate. +- **Still open:** `BHoM_Guid`, `Tags` and `CustomData` pass the Bar test; the special float values (`NaN`, infinity), `System.Type`, `Color` and a generic class's `_t` + (written without its type arguments) are not checked against C# output yet. Loading real JSON written by BHoM in C# is only covered through the published schemas, no + recorded C# sample is in the repository. + +--- + +## Step 5 (Issue 3): let objects with interface-typed fields be saved to JSON and loaded back + +> **Revised 2026-10-29** after reading the documentation of BHoM_JSONSchema. The first version of this step invented its own way of +> handling interfaces. BHoM already has a written, published way, so this step now follows it. **Approved 2026-10-29** (BHoM JSON is back in +> scope) and done: see "What was done in Step 5" above. + +### What's wrong, in plain words + +The point of the package (the "Re-use schema" arrow in the system design) is that other Python projects use these classes. +In practice, a Python project almost always needs to **save** objects and **load** them again: to a file, through a web API, +or to and from C# through Python_Toolkit. That is done with JSON. + +Today that works for simple classes, but not for most of BHoM, because BHoM uses interfaces everywhere. For example, the +structural `Edge` class has a field `Curve: ICurve`, which can hold a `Line`, an `Arc`, a `Polyline` and so on. We tested it: + +```python +edge = Edge(Curve=Line(Start=Point(X=1), End=Point(X=2))) # creating it works +text = edge.model_dump_json() # saving to JSON works... +Edge.model_validate_json(text) # ...but loading it back FAILS +Edge.model_json_schema() # describing it as JSON Schema FAILS +``` + +Why: +- The saved JSON doesn't say *which kind* of curve it was. It only contains `{"Start": ..., "End": ...}`. When loading, + Pydantic sees "an `ICurve`" and can't know whether to build a `Line` or an `Arc`, so it gives up. +- Every `BHoMObject` has a `Fragments` field typed with the interface `IFragment`, so describing the class as a JSON Schema + fails for **every** BHoM object. +- BHoM's C# JSON adds a `"_t"` entry to every object, holding the full C# class name (for example + `"_t": "BH.oM.Geometry.Line"`). Our Python doesn't write or read `_t`, so JSON can't pass between Python and C#. + +So without this step, people can create BHoM objects in Python, but can't reliably save them, load them, or exchange them with +C#. That is most of what "reusing the schema" means. + +### What BHoM_JSONSchema already does (and so what this step copies) + +BHoM_JSONSchema is generated from the same C# classes, and its documentation describes how it handles interfaces. Its rules: + +| BHoM_JSONSchema rule | Meaning | +|---|---| +| Every class has an optional `_t` that must equal the C# class name | A plain `Line` can be written without `_t`, but when it is written it must be right | +| An interface schema lists **every class that implements it**, as the allowed values of `_t` (for example `IPolyline`: `Polyline` or `Polygon`; `IFragment` lists about 130 classes from some 40 repositories) | The set of allowed classes is fixed when the schemas are generated | +| A value validated as an interface **must** carry `_t` | `_t` is how the right class is picked | +| After `_t` picks the class, the value is validated against that class only (an "if `_t` is X then X's schema" rule) | An error names the real problem (for example "Z is missing"), not a failure for every possible class | +| Enums are written as their names: `"Centre"`. Top-level enums, and enums held in an `object` property, are written as an object `{"_t": "System.Enum", "TypeName": {...}, "Value": "Centre"}`. The schema accepts both | | +| Every class also has an optional `_bhomVersion` text (for upgrades between BHoM versions) | | +| The schemas must always be generated as one full package, because an interface has to know all of its implementers | A class in a repository that is not converted cannot be validated against its interface | + +**Is this compatible with what I first proposed? Yes, and it improves it.** The first proposal (a lookup table from `_t` to class, +chosen when loading) is the same idea. The differences it removes: + +- **How the allowed classes are decided.** I had a table filled in as modules were imported. BHoM fixes the list when it generates. + We copy that: the generator writes the list of implementers into each interface (as text, like `generic_params`), so the + Python and the JSON Schema agree on exactly which classes an interface accepts. +- **How errors read.** I had not thought about this. BHoM picks the class from `_t` first, so the message names the real problem. + Pydantic's "tagged union" does the same, so we get the same behaviour. +- **Enums.** I had left open whether enums are saved by name or number. BHoM saves names. Our generated enums are numbers + (`None_ = 0`), so they would save as numbers and not match. They need to save as names, and load from a name or from BHoM's + object form. + +**One thing not copied: a plain Pydantic `Union` of all the implementers.** It is the closest built-in match to the JSON Schema, but +each interface field would have to import every implementer. `IFragment` alone has about 130, in 40 repositories, and the classes +refer to each other, which would create import cycles and a very slow import. So the implementers stay as text, and a class is +imported only when a `_t` value names it. + +**The same limit applies.** An interface only accepts classes that are in the generated package. With the scope of Step 6 +(BHoM/BHoM plus the test repository), an interface such as `IFragment` accepts only its implementers in those repositories, and +loading JSON that names another class gives a clear error ("`BH.oM.X.Y` is not in bhom_schema"). This is the same as the BHoM_JSONSchema +warning about partial generation, and it goes away as more repositories are converted. + +### The decision + +| Option | What you get | Effort | +|---|---|---| +| **Stop at the current state** | Python objects can be created but not reliably saved or loaded | none | +| **Follow the BHoM JSON Schema rules** (recommended) | Python saves and loads JSON that BHoM_JSONSchema accepts, and loads what BHoM C# writes. Interface fields, JSON Schema output and enums all work | Medium. Smaller than the old "5B", because the rules are written down and the published schemas can serve as the test | + +The old split into 5A (Python only) and 5B (compatible with C#) is gone: following the published rules is one piece of work, and it +is also the lower-risk one, because there is no second format to invent and maintain. + +### What we will do + +5.1 **Record the decision** and update CODING-PLAN section 1 (BHoM JSON becomes in scope, as "the JSON that BHoM_JSONSchema describes"). + +5.2 **`_t` and `_bhomVersion` on every class.** Every generated class gets an optional `_t`, fixed to its C# class name, and an +optional `_bhomVersion`. Python field names cannot start with `_`, so they are written with an alias (`_t` in the JSON) and objects +are saved using the aliases. The reader in py2cs ignores both. + +5.3 **The implementers of an interface, written by the generator.** Each interface module gets a list of the C# names of the classes +that implement it, from the classes the generator converts (like `generic_params`, as text, with no imports). + +5.4 **One reusable type for interface fields.** In `src/bhom_schema/_support`, a `Polymorphic[ICurve]` type. To validate a value: +if it is already an object of an allowed class, accept it; if it is JSON, require `_t`, check it is one of the interface's +implementers, import that class only now, and validate against it alone. When saving, write the real class with its `_t`. The +generator writes `Polymorphic[...]` for interface-typed and abstract-typed fields, and for lists and dictionaries of them. + +5.5 **JSON Schema output that matches BHoM_JSONSchema.** `model_json_schema()` describes a `Polymorphic` field the way BHoM does +(`_t` required, one of the implementers, then that class's schema), so it works for every class, `BHoMObject.Fragments` included. + +5.6 **Enums as names.** Generated enums save as their name (`None_` saves as `"None"`, using the renamed-members record) and load +from a name, a number, or BHoM's object form `{"_t": "System.Enum", "TypeName": {...}, "Value": "..."}`. Check flags enums and +the other special values (`BHoM_Guid`, `CustomData`, `FragmentSet`, `NaN`) against the published schemas and C# output before fixing +their form. + +5.7 **Keep py2cs working**: it learns to ignore `_t` and `_bhomVersion`, and to read `Polymorphic[...]` as the plain interface. + +### How we'll know it works + +- The `Edge` example above saves and loads correctly, and a loaded `ICurve` is the right class (`Line`, not just any curve). +- `model_json_schema()` works for every generated class. +- **Conformance with BHoM_JSONSchema.** JSON saved from Python objects validates against the matching schemas of BHoM_JSONSchema (a + read-only clone, used only as the test, so the generator still does not depend on that repository), and an object with a missing + field is rejected by both with the same reason. Skipped when the clone is not there. +- JSON written by BHoM in C# (a few recorded samples) loads in Python and saves back as equivalent JSON. +- An error for a bad interface value names the real problem, and a `_t` that is not an implementer is refused with a clear message. (Python is looser than the + published schema in one way: a field that has a default may be left out, while BHoM_JSONSchema lists every field as required.) +- Loading one class doesn't import unrelated parts of the package. +- The build and test gate passes. + +--- + +## What was done in Step 6 + +- **6.2 attribute classes:** `TypeSelector` skips every class based on `System.Attribute`. +- **6.3 version:** the generator reads `Major.Minor` from the assembly version of `BHoM.dll`, or of another loaded assembly when it is missing (`BHoMAdapter.BHoMVersion`; every BHoM project sets ``, which DevOps raises), and, on a full run (not `--types`), writes + `bhom_schema/_version.py` with `__bhom_version__` and `__version__` (`X.Y.0.dev0`, or `X.Y.0` with `--release X.Y`). The file is only rewritten when its + text changes. `--release` fails with exit code 1 when `X.Y` is not the version read. `pyproject.toml` takes the version from that file, `bhom_schema.__version__` + shows it, and `py2cs` and the test helpers skip `_version.py`. The committed `_version.py` says `10.0`, the version the CI builds; the next weekly run corrects it + if BHoM says otherwise (a laptop with another BHoM installed, such as 9.3, would write that). +- **6.4 notes:** `python -m py2cs notes --head-root ... [--base-root ... --previous TAG] --version X.Y.0 --out notes.md`. +- **6.5 `release.yml`:** manual, dry run by default, the steps of 6.5 above; the build job has `contents: write`, only the TestPyPI job has `id-token: write`, and no + secret is used. +- **Tests:** NUnit `VersionTests` (attribute classes, the version read, `_version.py` once, `--release` match and mismatch, no file for a partial run); + pytest `test_notes.py` and two workflow tests. A wheel built from the repository is named `bhom_schema-10.0.0.dev0`. +- **Not verified:** the release workflow has never run on GitHub; TestPyPI trusted publishing is not set up (an administrator does it once, see + [Workflows.md](../Workflows.md)); real PyPI is left for when BHoM/BHoM is in scope; the relation between a BHoM release number and the `AssemblyVersion` is + assumed (the `--release` check stops a wrong name). + +--- + +## Step 6 (Issue 6): the scope, a version number, and publishing + +> **Revised 2026-10-29** after reading BHoM_JSONSchema's releases, BHoM's versioning code and Step 5. The scope is the CSharpSchema_Test +> repository for now; BHoM/BHoM comes later. + +### What's wrong, in plain words + +- It also includes C# *attribute* classes (the things written in square brackets, like `[Description]`). They aren't data, so they don't + belong in the package. +- The version is always `0.1.0`, so users can't tell which BHoM release the classes match, and nobody can `pip install` the package. + +### What we decided + +| Question | Decision | +|---|---| +| Scope | Only `BHoM/CSharpSchema_Test` for now (already the case: `Converted-repositories.txt` and `--repos`). BHoM/BHoM is added later by adding it to the two repository lists | +| What is a version for? | pip needs one, users pin and upgrade by it, and a release is a point you can name in a bug report. Nothing else: Python users do not run Versioning_Toolkit | +| Where does the number come from? | From BHoM, not typed by hand: the generator reads the `Major.Minor` of the `AssemblyVersion` of `BHoM.dll`, or of any loaded assembly when it is missing (for example `10.0`; DevOps raises it in every project) | +| Version in every class marker? | No | +| Stamping `_bhomVersion` when saving JSON, a warning when loading JSON of another version, refusing unknown keys? | No. Python never writes JSON for C# to upgrade, so nothing needs them. The optional `bhom_version` field stays, so the published schemas and the classes agree, and a value that is set is written and read | +| How are releases made? | Like BHoM_JSONSchema: `develop` is regenerated every week; at the end of a milestone a branch `vX.Y` is made, regenerated, tagged and released, and then left alone | +| Where is it published? | A GitHub Release with the wheel, and TestPyPI. Real PyPI when BHoM/BHoM is in scope: putting the test classes there would keep them on a public index for good | + +### What we will do + +6.1 **Scope:** nothing new. It is done (`--repos`, `Converted-repositories.txt`). + +6.2 **Leave out attribute classes.** The generator skips any class based on `System.Attribute`. (The list of test-only assemblies is not +needed until BHoM/BHoM is in scope.) + +6.3 **Version from BHoM.** The generator writes `bhom_schema/_version.py`: +- `__bhom_version__ = "10.0"`: the BHoM `Major.Minor` of the assemblies it read. +- `__version__ = "10.0.0.dev0"` on `develop` (the committed value only changes when the BHoM version changes, so the weekly run does not + make a commit just for a date), and `"10.0.0"` for a release. +- `--release X.Y` makes a release version. It fails when `X.Y` is not the BHoM version it read, so a release cannot be named wrongly. +- `pyproject.toml` takes the package version from `_version.py`, and `bhom_schema.__version__` shows it. + +6.4 **Release notes.** `python -m py2cs notes` compares the classes of the previous release with the new ones and writes the added and +removed classes and a "Breaking changes" section (removed or renamed properties, changed types and so on, the same list the linked C# pull +request uses). That is how a Python user learns what to change before upgrading. + +6.5 **`release.yml`** (by hand, input `X.Y`, with a dry run): clones and builds the C# repositories, regenerates with `--release`, +checks the result, commits it to a new branch `vX.Y`, tags `vX.Y.0`, builds the wheel, creates a GitHub Release with the wheel and the +release notes, and publishes to TestPyPI. TestPyPI is reached with "trusted publishing" (GitHub proves who it is, so there is no password or +token to store); that is set up once on TestPyPI. + +### How we'll know it works + +- Tests: attribute classes are not generated; `_version.py` holds the BHoM version and `X.Y.0.dev0`; `--release` gives `X.Y.0` and fails for a + different `X.Y`; the release notes list added, removed and breaking changes; the release workflow has no push or schedule trigger, only + the publish job can ask for the identity token, and it uses no stored secret. +- A wheel built from the repository installs and reports the right version. +- The build and test gate passes. + +--- + +## Step 7 (Issue 7): five small generator fixes + +Each is small and independent; do them in any order. + +7.1 **The copyright year.** Every generated file starts with `Copyright (c) 2015 - 2026`, typed into the code. In January +someone changes it to 2027, and **every** generated file changes at once, which makes a huge, meaningless PR. Fix: leave the +year range out of generated Python files, or read it from one setting that is changed on purpose in its own PR. C# files must +still follow the BHoM copyright rule. + +7.2 **Old folders left behind.** When a run doesn't include an assembly at all (for example because its build failed), its old +Python files stay, and nobody is told. Fix: a full run lists every assembly folder it didn't generate; the weekly job can also +delete them with a new option, `--clean-missing-assemblies`. + +7.3 **Two classes with the same file name.** On Windows, file names ignore upper and lower case, so `Panel` and `PANEL` would land +in the same file. Today one class is dropped with only a warning. Fix: make it an error that stops the run. + +7.4 **`DateTimeOffset` turns into `DateTime`.** C# has two date types; both become Python `datetime`, and on the way back the +difference is lost. Fix: remember the original C# type in the field's `ClrType` marker. + +7.5 **`Included-repositories.txt`**: it spells the dKoP repository `dKop_Toolkit` and the file doesn't end with a newline, which +makes some tools skip the last line. Fix both. (This file is copied from the JSON repository; keep it in line with theirs.) + +### How we'll know it works + +One test per fix, then the build and test gate passes. + +--- + +## Step 8 (Issue 8): tidy the documentation + +8.1 **CODING-PLAN.md** still says "plan only, no code written yet", right above a section saying phases 0 to 6 are done. Move the +status to the top, remove the old line, and record the decisions from this audit. + +8.2 **Reusing-oM-Classes.md** is a pasted chat answer, and part of it is out of date (it says `Tree_1` fails to import; that was +fixed). Rewrite it as a short reference, or merge it into the gotchas document. + +8.3 **The same commands are repeated** in "Converting all oM classes" and "Generating the real package", including small scripts +you copy and paste. Keep one procedure, and turn the scripts into proper commands (for example `python -m py2cs import-all` +and `python -m py2cs compare`) that the guides and the workflows both use. + +8.4 **README**: add the rules for editing the Python (Step 1), the PR rules (Step 3), how to install and use the package, and +links to the other guides. + +8.5 **Testing guide**: update the test counts and describe the new tests (patcher, alignment check, JSON). + +### How we'll know it works + +The docs match the code and workflows, nothing in them is out of date, and a final run of the build and test gate passes. diff --git a/docs/audit-1/audit-1.md b/docs/audit-1/audit-1.md new file mode 100644 index 0000000..f8b52db --- /dev/null +++ b/docs/audit-1/audit-1.md @@ -0,0 +1,83 @@ +Docs read; now reviewing the remaining docs and the converter code. + +Reviewing the Python→C# converter (py2cs) now. + +Confirmed a serialization gap with a probe; checking a couple of default-value edge cases next. + +## Verdict + +**It's partly fit for purpose.** The C#→Python generator works well, but the rest of the Option 2 loop isn't ready yet. The Python→C# return path is weak, Python projects can't easily reuse the output (it doesn't survive JSON), and the CI/PR automation (phase 8) hasn't been started. + +**What's already good:** +- It reads the compiled oM by reflection. +- It selects the same types as BHoM_JSONSchema. +- Output is deterministic, and reference cycles are handled. +- The corpus run converts 1,800 of 1,800 types, all import, and Pyright strict is clean. +- The fixture assemblies and the Roslyn round trip are a strong testing approach. +- The docs are thorough. + +## Main problems, most serious first + +Each was checked with a throwaway probe in the scratchpad against the existing `_output` and your local `dKoP_Toolkit` and `BHoM` clones. + +**1. The drafted C# would probably get rejected by BHoM reviewers.** I added one Python field to `Bar`. The full-file replacement of `Structure_oM/Elements/Bar.cs` then: +- dropped both `[DocumentationURL]` attributes; +- removed `IElement1D` and reordered the bases; +- collapsed the multi-line `[Description]` strings; +- dropped `= null` and changed `= 0` to `= 0.0`. + +So one line of Python became a ~40-line C# diff that loses information. The Python model doesn't carry every C# attribute, so regenerating the whole file will always lose something. **Fix:** a member-level patch using Roslyn (add, change or remove only the affected members, keeping comments and formatting). Roslyn is already used in the tests. This means revisiting the "full-file replacement" decision in CODING-PLAN §9. + +**2. py2cs only understands the generator's own output, but the Python PRs will be written by people.** +- A new class without a hand-written `__bhom__` dict is **silently ignored**. The diff reports no change, so no C# PR would be raised. +- `Stage: int = Field(0, description=...)` (a positional default, which is normal Pydantic) is read as "no default". The draft then made `ProjectIdentification` immutable, with a get-only property and a new constructor. That breaks every `new ProjectIdentification()`. +- `Optional[int]` crashes the whole run with a traceback. + +**Fix:** +- Define a small authoring contract and enforce it with a PR check. +- Accept the common idioms (`Optional`, `typing.List`, positional `Field`, `pydantic.Field`). +- Report problems per file instead of crashing. +- Infer the assembly and repo from the folder. Default brand-new types to `Python_Toolkit`, as your diagram suggests. +- Never emit immutable C# from a missing default. Flag it instead. + +**3. Python projects can't really reuse the schema yet.** Fields typed with an interface (`ICurve`, `IFragment`, …) are plain `isinstance` checks. +- `Edge.model_validate_json(edge.model_dump_json())` fails. +- `model_json_schema()` fails for **every** `BHoMObject`, because of `Fragments: list[IFragment]`. +- There's no `_t` type marker, so the dump loses which `ICurve` subtype it held. + +**Fix:** add a `_t` discriminator to concrete classes, and validate interface-typed fields through a registry keyed on `_t` (or a union of the known implementers). Line this up with the BHoM JSON serialiser. The plan put BHoM JSON out of scope; I'd bring it back, because reuse depends on it, and Python_Toolkit already talks BHoM JSON. + +**4. Nothing defines what happens to the two linked PRs over time.** If the Python PR merges and the C# PR is then changed or rejected, the next weekly run silently overwrites or reverts the Python. **Fix:** +- Treat the Python PR as a proposal. +- Add a required "aligned" check: build the linked C# branch, regenerate the affected types, and compare. You already have `compare_manifests` for this. +- Merge (or supersede) the Python PR only after the C# PR merges. +- Flag renames, removals and type changes as breaking. BHoM needs Versioning_Toolkit upgraders for those. + +**5. Phase 8 doesn't exist yet.** +- There are no workflows, no `pr.py` or GitHub client, and no generated code committed in `src/bhom_schema`. +- The generator always exits with 0, so CI couldn't gate on failed types or new kinds of warning. +- Input is whatever happens to be in `ProgramData`. **Fix:** build once and emit both JSON and Python from the same weekly build (share the build output, not the logic), or use released BHoM assemblies. + +**6. Packaging.** +- The version is fixed at `0.1.0`, and there's no publishing. +- One package holds about 70 oM assemblies, including `UnitTest_oM`, `NUnit_oM` and the attribute classes. + +**Fix:** tie the package version to the BHoM release, record it in `__bhom__`, exclude test and attribute types, and consider per-toolkit extras. + +**7. Smaller issues:** +- The header hard-codes `2015 - 2026`, so every January all ~2,000 files will change. Drop the year or keep it fixed. +- Clean-up only touches assemblies seen in the current run. A toolkit whose build failed keeps stale files with no warning. +- A case-insensitive path collision drops a type, with only a warning. +- `DateTimeOffset` comes back as `DateTime`. +- `Included-repositories.txt` spells it `dKop_Toolkit` and has no trailing newline. + +**8. Docs:** +- `CODING-PLAN.md` still says "plan only, no code written yet" above its own status section. +- `Reusing-oM-Classes.md` is a pasted chat answer and is out of date: it says `Tree_1` fails, which has since been fixed. +- Commands are repeated across three guides. + +## Suggested order + +Do 2 and 1 first, because they make the loop safe. Then 4, then 5, so the loop actually runs. Do 3 alongside, because it decides whether anyone can use the package. Leave 6 to 8 for last. + +I couldn't run `run-all.ps1`: the permission was denied, so I haven't confirmed the test suite currently passes. I can also put this review on a shareable page if you want to pass it on. \ No newline at end of file diff --git a/docs/templates/NewClass.py b/docs/templates/NewClass.py new file mode 100644 index 0000000..85eb273 --- /dev/null +++ b/docs/templates/NewClass.py @@ -0,0 +1,27 @@ +"""Template for a hand-written class in src/bhom_schema. Copy it to //.py and edit it. + +No __bhom__ marker is needed: py2cs works it out from the folder and the generated classes next to it. +""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field + +from bhom_schema._support import ClrType, Quantity +from bhom_schema.BHoM.BHoMObject import BHoMObject + +# Derive from SchemaModel (from bhom_schema._support) instead when the class has no BHoM base class +class Example(BHoMObject): + """What this class describes. BHoM requires a description on every oM class.""" + # Text, with a default and a description + Reference: str = Field("", description="The project reference.") + # A whole number; ClrType('long') says the C# type is long rather than int + Count: Annotated[int, ClrType("long")] = Field(0, description="How many there are.") + # A physical quantity: the C# property gets the [Length] attribute + Span: Annotated[float, Quantity("Length", "m")] = Field(1.5, description="Distance between the supports.") + # Optional value: "or nothing" + Stage: int | None = Field(None, description="The RIBA stage, when known.") + # A list starts empty for every new object + Tags: list[str] = Field(default_factory=list, description="Free labels.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0e2f13 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "bhom-schema" +dynamic = ["version"] +description = "Python classes generated from the BHoM C# object model (oM)." +requires-python = ">=3.12" +license = { file = "LICENSE" } +dependencies = ["pydantic>=2.13,<3"] + +[project.optional-dependencies] +dev = ["pytest>=8", "pyright==1.1.414", "pyyaml>=6", "types-PyYAML", "jsonschema>=4.18", "types-jsonschema"] + +[tool.setuptools.dynamic] +version = { attr = "bhom_schema._version.__version__" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src", ".ci/generation", ".ci/unit-tests/python"] +testpaths = [".ci/unit-tests/python"] +addopts = "-ra" +markers = ["realtypes: needs the real BHoM assemblies installed"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..d4a0331 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,7 @@ +{ + "include": ["src", ".ci/generation/py2cs", ".ci/unit-tests/python"], + "exclude": ["**/__pycache__", ".ci/unit-tests/_output"], + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "extraPaths": ["src", ".ci/generation"] +} diff --git a/src/bhom_schema/BHoM/BHoMObject.py b/src/bhom_schema/BHoM/BHoMObject.py new file mode 100644 index 0000000..abea399 --- /dev/null +++ b/src/bhom_schema/BHoM/BHoMObject.py @@ -0,0 +1,31 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Base.BHoMObject. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar +from uuid import UUID, uuid4 + +from pydantic import Field + +from bhom_schema.BHoM.IBHoMObject import IBHoMObject +from bhom_schema.BHoM.IFragment import IFragment +from bhom_schema._support import ClrType, Polymorphic, SchemaModel + + +class BHoMObject(SchemaModel, IBHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Base.BHoMObject', + 'assembly': 'BHoM', + 'namespace': 'BH.oM.Base', + 'repo': 'BHoM/BHoM', + 'kind': 'class', + } + + BHoM_Guid: UUID = Field(default_factory=uuid4) + Name: str = '' + Fragments: Annotated[list[Polymorphic[IFragment]], ClrType('BH.oM.Base.FragmentSet')] = Field(default_factory=list) + Tags: set[str] = Field(default_factory=set) + CustomData: dict[str, Any] = Field(default_factory=dict) diff --git a/src/bhom_schema/BHoM/IBHoMObject.py b/src/bhom_schema/BHoM/IBHoMObject.py new file mode 100644 index 0000000..9b25727 --- /dev/null +++ b/src/bhom_schema/BHoM/IBHoMObject.py @@ -0,0 +1,32 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Base.IBHoMObject. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IBHoMObject(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Base.IBHoMObject', + 'assembly': 'BHoM', + 'namespace': 'BH.oM.Base', + 'repo': 'BHoM/BHoM', + 'kind': 'interface', + 'properties': {'BHoM_Guid': {'type': 'System.Guid'}, 'Name': {'type': 'string'}, 'Fragments': {'type': 'BH.oM.Base.FragmentSet'}, 'Tags': {'type': 'System.Collections.Generic.HashSet'}, 'CustomData': {'type': 'System.Collections.Generic.Dictionary'}}, + 'implementers': { + 'BH.oM.Base.BHoMObject': 'BHoM.BHoMObject', + 'BH.oM.FixtureAlpha.Beam': 'FixtureAlpha_oM.Beam', + 'BH.oM.FixtureAlpha.Collections': 'FixtureAlpha_oM.Collections', + 'BH.oM.FixtureAlpha.GeometryHolder': 'FixtureAlpha_oM.GeometryHolder', + 'BH.oM.FixtureAlpha.GridHolder': 'FixtureAlpha_oM.GridHolder', + 'BH.oM.FixtureAlpha.ImmutableThing': 'FixtureAlpha_oM.ImmutableThing', + 'BH.oM.FixtureAlpha.Scalars': 'FixtureAlpha_oM.Scalars', + 'BH.oM.FixtureAlpha.SparseHolder': 'FixtureAlpha_oM.SparseHolder', + 'BH.oM.FixtureAlpha.SparseParent': 'FixtureAlpha_oM.SparseParent', + 'BH.oM.FixtureBeta.Elements.UsesBoth': 'FixtureBeta_oM.Elements.UsesBoth', + }, + } diff --git a/src/bhom_schema/BHoM/IFragment.py b/src/bhom_schema/BHoM/IFragment.py new file mode 100644 index 0000000..2f5051c --- /dev/null +++ b/src/bhom_schema/BHoM/IFragment.py @@ -0,0 +1,21 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Base.IFragment. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IFragment(IObject): + """Describes objects that can exist independently or can be attached to other BHoM objects as part of their `FragmentSet` property.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Base.IFragment', + 'assembly': 'BHoM', + 'namespace': 'BH.oM.Base', + 'repo': 'BHoM/BHoM', + 'kind': 'interface', + 'implementers': {}, + } diff --git a/src/bhom_schema/BHoM/IImmutable.py b/src/bhom_schema/BHoM/IImmutable.py new file mode 100644 index 0000000..bfc611f --- /dev/null +++ b/src/bhom_schema/BHoM/IImmutable.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Base.IImmutable. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IImmutable(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Base.IImmutable', + 'assembly': 'BHoM', + 'namespace': 'BH.oM.Base', + 'repo': 'BHoM/BHoM', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.ImmutableThing': 'FixtureAlpha_oM.ImmutableThing', + }, + } diff --git a/src/bhom_schema/BHoM/IObject.py b/src/bhom_schema/BHoM/IObject.py new file mode 100644 index 0000000..c0d29bc --- /dev/null +++ b/src/bhom_schema/BHoM/IObject.py @@ -0,0 +1,36 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.Base.IObject. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from abc import ABC +from typing import Any, ClassVar + + +class IObject(ABC): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.Base.IObject', + 'assembly': 'BHoM', + 'namespace': 'BH.oM.Base', + 'repo': 'BHoM/BHoM', + 'kind': 'interface', + 'implementers': { + 'BH.oM.Base.BHoMObject': 'BHoM.BHoMObject', + 'BH.oM.FixtureAlpha.Beam': 'FixtureAlpha_oM.Beam', + 'BH.oM.FixtureAlpha.Collections': 'FixtureAlpha_oM.Collections', + 'BH.oM.FixtureAlpha.CycleA': 'FixtureAlpha_oM.CycleA', + 'BH.oM.FixtureAlpha.CycleB': 'FixtureAlpha_oM.CycleB', + 'BH.oM.FixtureAlpha.GeometryHolder': 'FixtureAlpha_oM.GeometryHolder', + 'BH.oM.FixtureAlpha.GridHolder': 'FixtureAlpha_oM.GridHolder', + 'BH.oM.FixtureAlpha.ImmutableThing': 'FixtureAlpha_oM.ImmutableThing', + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + 'BH.oM.FixtureAlpha.PointLike': 'FixtureAlpha_oM.PointLike', + 'BH.oM.FixtureAlpha.ProjectLike': 'FixtureAlpha_oM.ProjectLike', + 'BH.oM.FixtureAlpha.Scalars': 'FixtureAlpha_oM.Scalars', + 'BH.oM.FixtureAlpha.SparseHolder': 'FixtureAlpha_oM.SparseHolder', + 'BH.oM.FixtureAlpha.SparseParent': 'FixtureAlpha_oM.SparseParent', + 'BH.oM.FixtureBeta.Elements.UsesBoth': 'FixtureBeta_oM.Elements.UsesBoth', + 'BH.oM.FixtureBeta.PointLike': 'FixtureBeta_oM.PointLike', + }, + } diff --git a/src/bhom_schema/BHoM/__init__.py b/src/bhom_schema/BHoM/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bhom_schema/FixtureAlpha_oM/Beam.py b/src/bhom_schema/FixtureAlpha_oM/Beam.py new file mode 100644 index 0000000..a3e5ded --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Beam.py @@ -0,0 +1,33 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Beam. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.Options import Options +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema.FixtureAlpha_oM.SupportKind import SupportKind +from bhom_schema._support import ClrType, DisplayText, Quantity + + +class Beam(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Beam', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Span: Annotated[float, Quantity('Length', 'm'), DisplayText('Span length')] = Field(default=1.5, description='Distance between the supports.') + Support: SupportKind = SupportKind.Pinned + Flags: Options = Options.A + Start: PointLike | None = None + Count: Annotated[int, ClrType('long')] = 3 + Small: Annotated[float, ClrType('float')] = 0.5 diff --git a/src/bhom_schema/FixtureAlpha_oM/Collections.py b/src/bhom_schema/FixtureAlpha_oM/Collections.py new file mode 100644 index 0000000..7fd68fa --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Collections.py @@ -0,0 +1,35 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Collections. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema._support import ClrType + + +class Collections(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Collections', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Names: set[str] = Field(default_factory=set) + Points: Annotated[list[PointLike], ClrType('System.Collections.Generic.HashSet')] = Field(default_factory=list) + Lookup: dict[str, float] = Field(default_factory=dict) + ByPoint: Annotated[list[tuple[PointLike, str]], ClrType('System.Collections.Generic.Dictionary')] = Field(default_factory=list) + Values: Annotated[list[float], ClrType('double[]')] = Field(default_factory=list) + Matrix: list[list[int]] = Field(default_factory=list) + Words: Annotated[list[str], ClrType('System.Collections.Generic.IEnumerable')] = Field(default_factory=lambda: ['a', 'b']) + OptionalCount: int | None = None + Pair: tuple[int, str] | None = None + Anything: Any | None = None diff --git a/src/bhom_schema/FixtureAlpha_oM/CycleA.py b/src/bhom_schema/FixtureAlpha_oM/CycleA.py new file mode 100644 index 0000000..1bea8d6 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/CycleA.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.CycleA. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class CycleA(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.CycleA', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Other: CycleB | None = None + + +from bhom_schema.FixtureAlpha_oM.CycleB import CycleB + +CycleA.model_rebuild() diff --git a/src/bhom_schema/FixtureAlpha_oM/CycleB.py b/src/bhom_schema/FixtureAlpha_oM/CycleB.py new file mode 100644 index 0000000..e865cb2 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/CycleB.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.CycleB. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class CycleB(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.CycleB', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Other: CycleA | None = None + + +from bhom_schema.FixtureAlpha_oM.CycleA import CycleA + +CycleB.model_rebuild() diff --git a/src/bhom_schema/FixtureAlpha_oM/GeometryHolder.py b/src/bhom_schema/FixtureAlpha_oM/GeometryHolder.py new file mode 100644 index 0000000..9a82c51 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/GeometryHolder.py @@ -0,0 +1,28 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.GeometryHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.ICurveLike import ICurveLike +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike +from bhom_schema._support import Polymorphic + + +class GeometryHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.GeometryHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Geometry: Polymorphic[IGeometryLike] | None = None + Curves: list[Polymorphic[ICurveLike]] = Field(default_factory=list) diff --git a/src/bhom_schema/FixtureAlpha_oM/GridHolder.py b/src/bhom_schema/FixtureAlpha_oM/GridHolder.py new file mode 100644 index 0000000..1f17f28 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/GridHolder.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.GridHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema._support import ClrType + + +class GridHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.GridHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Matrix: Annotated[list[list[float]], ClrType('double[,]')] = Field(default_factory=lambda: [[0.0, 0.0], [0.0, 0.0]]) + Table: Annotated[dict[str, float], ClrType('System.Collections.ObjectModel.ReadOnlyDictionary')] = Field(default_factory=dict) diff --git a/src/bhom_schema/FixtureAlpha_oM/ICurveLike.py b/src/bhom_schema/FixtureAlpha_oM/ICurveLike.py new file mode 100644 index 0000000..08004ac --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/ICurveLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ICurveLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.IElement1DLike import IElement1DLike +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike + + +class ICurveLike(IElement1DLike, IGeometryLike): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ICurveLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/IElement1DLike.py b/src/bhom_schema/FixtureAlpha_oM/IElement1DLike.py new file mode 100644 index 0000000..548d77b --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/IElement1DLike.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IElement1DLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IElement1DLike(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IElement1DLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/IGeometryLike.py b/src/bhom_schema/FixtureAlpha_oM/IGeometryLike.py new file mode 100644 index 0000000..e469e36 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/IGeometryLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IGeometryLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class IGeometryLike(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IGeometryLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + 'BH.oM.FixtureAlpha.PointLike': 'FixtureAlpha_oM.PointLike', + }, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/ILabelled.py b/src/bhom_schema/FixtureAlpha_oM/ILabelled.py new file mode 100644 index 0000000..f2b664e --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/ILabelled.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ILabelled. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class ILabelled(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ILabelled', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'properties': {'Label': {'type': 'string'}}, + 'implementers': { + 'BH.oM.FixtureAlpha.LineLike': 'FixtureAlpha_oM.LineLike', + }, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/IOpeningLike_1.py b/src/bhom_schema/FixtureAlpha_oM/IOpeningLike_1.py new file mode 100644 index 0000000..7dcc986 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/IOpeningLike_1.py @@ -0,0 +1,20 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IOpeningLike`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IBHoMObject import IBHoMObject + + +class IOpeningLike_1[T](IBHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IOpeningLike`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'generic_params': {'T': []}, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/IPanelLike_2.py b/src/bhom_schema/FixtureAlpha_oM/IPanelLike_2.py new file mode 100644 index 0000000..0d23c7d --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/IPanelLike_2.py @@ -0,0 +1,21 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.IPanelLike`2. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IBHoMObject import IBHoMObject +from bhom_schema.FixtureAlpha_oM.IOpeningLike_1 import IOpeningLike_1 + + +class IPanelLike_2[TEdge, TOpening: IOpeningLike_1[Any]](IBHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.IPanelLike`2', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'generic_params': {'TEdge': [], 'TOpening': ['BH.oM.FixtureAlpha.IOpeningLike']}, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/ImmutableThing.py b/src/bhom_schema/FixtureAlpha_oM/ImmutableThing.py new file mode 100644 index 0000000..61b2167 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/ImmutableThing.py @@ -0,0 +1,24 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ImmutableThing. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.BHoM.IImmutable import IImmutable + + +class ImmutableThing(BHoMObject, IImmutable): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ImmutableThing', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Id: str + Length: float diff --git a/src/bhom_schema/FixtureAlpha_oM/LineLike.py b/src/bhom_schema/FixtureAlpha_oM/LineLike.py new file mode 100644 index 0000000..4f419f7 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/LineLike.py @@ -0,0 +1,31 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.LineLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.FixtureAlpha_oM.ICurveLike import ICurveLike +from bhom_schema.FixtureAlpha_oM.ILabelled import ILabelled +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema._support import SchemaModel + + +class LineLike(SchemaModel, ICurveLike, ILabelled): + """A straight segment between two points.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.LineLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Start: PointLike = Field(default_factory=lambda: PointLike()) + End: PointLike = Field(default_factory=lambda: PointLike()) + Infinite: bool = Field(default=False, description='Extends the line to infinity.') + Label: str | None = None diff --git a/src/bhom_schema/FixtureAlpha_oM/Options.py b/src/bhom_schema/FixtureAlpha_oM/Options.py new file mode 100644 index 0000000..35f3851 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Options.py @@ -0,0 +1,24 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Options. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMFlags + + +class Options(BHoMFlags): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Options', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + 'flags': True, + } + + A = 1 + B = 2 + Both = 3 diff --git a/src/bhom_schema/FixtureAlpha_oM/PointLike.py b/src/bhom_schema/FixtureAlpha_oM/PointLike.py new file mode 100644 index 0000000..efd3266 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/PointLike.py @@ -0,0 +1,25 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.PointLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.IGeometryLike import IGeometryLike +from bhom_schema._support import SchemaModel + + +class PointLike(SchemaModel, IGeometryLike): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.PointLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + X: float = 0.0 + Y: float = 0.0 + Z: float = 0.0 diff --git a/src/bhom_schema/FixtureAlpha_oM/ProjectLike.py b/src/bhom_schema/FixtureAlpha_oM/ProjectLike.py new file mode 100644 index 0000000..ac1f3dd --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/ProjectLike.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ProjectLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.FixtureAlpha_oM.ThingIdentity import ThingIdentity +from bhom_schema._support import SchemaModel + + +class ProjectLike(SchemaModel, ThingIdentity): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ProjectLike', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + ProjectReference: str = '' + ProjectName: str = '' + ProjectLocation: str = '' + AdditionalReferences: str = '' diff --git a/src/bhom_schema/FixtureAlpha_oM/Scalars.py b/src/bhom_schema/FixtureAlpha_oM/Scalars.py new file mode 100644 index 0000000..42612a2 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Scalars.py @@ -0,0 +1,40 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Scalars. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Annotated, Any, ClassVar +from uuid import UUID + +from pydantic import Field +from pydantic.json_schema import SkipJsonSchema + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema._support import ClrType, Color + + +class Scalars(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Scalars', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Created: datetime = Field(default_factory=datetime.now) + Fixed: datetime = datetime(2020, 1, 2, 3, 4, 5, 0) + Key: UUID = UUID(int=0) + Cost: Decimal = Decimal('1.5') + Colour: Color = Field(default_factory=Color) + Duration: timedelta = timedelta(seconds=90.0) + Letter: Annotated[str, ClrType('char')] = 'x' + Quote: str = 'it\'s "here"' + TypeRef: Annotated[Any | None, ClrType('System.Type')] = None + Callback: Annotated[SkipJsonSchema[Callable[..., Any]] | None, ClrType('System.Func')] = None + Big: float = 1.7976931348623157e+308 diff --git a/src/bhom_schema/FixtureAlpha_oM/Sparse.py b/src/bhom_schema/FixtureAlpha_oM/Sparse.py new file mode 100644 index 0000000..d2d6b36 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Sparse.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Sparse. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMEnum + + +class Sparse(BHoMEnum): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Sparse', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + } + + One = 1 + Two = 2 diff --git a/src/bhom_schema/FixtureAlpha_oM/SparseHolder.py b/src/bhom_schema/FixtureAlpha_oM/SparseHolder.py new file mode 100644 index 0000000..211da26 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/SparseHolder.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SparseHolder. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.Sparse import Sparse + + +class SparseHolder(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SparseHolder', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Level: Sparse diff --git a/src/bhom_schema/FixtureAlpha_oM/SparseParent.py b/src/bhom_schema/FixtureAlpha_oM/SparseParent.py new file mode 100644 index 0000000..a510c0e --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/SparseParent.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SparseParent. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.SparseHolder import SparseHolder + + +class SparseParent(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SparseParent', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + Holder: SparseHolder diff --git a/src/bhom_schema/FixtureAlpha_oM/SupportKind.py b/src/bhom_schema/FixtureAlpha_oM/SupportKind.py new file mode 100644 index 0000000..46408db --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/SupportKind.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.SupportKind. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema._support import BHoMEnum + + +class SupportKind(BHoMEnum): + """Kinds of support.""" + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.SupportKind', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'enum', + 'renamed_members': {'None_': 'None'}, + } + + None_ = 0 + # Fully fixed. + Fixed = 1 + Pinned = 2 diff --git a/src/bhom_schema/FixtureAlpha_oM/ThingIdentity.py b/src/bhom_schema/FixtureAlpha_oM/ThingIdentity.py new file mode 100644 index 0000000..861dd35 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/ThingIdentity.py @@ -0,0 +1,22 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.ThingIdentity. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject + + +class ThingIdentity(IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.ThingIdentity', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'interface', + 'implementers': { + 'BH.oM.FixtureAlpha.ProjectLike': 'FixtureAlpha_oM.ProjectLike', + }, + } diff --git a/src/bhom_schema/FixtureAlpha_oM/TreeLike_1.py b/src/bhom_schema/FixtureAlpha_oM/TreeLike_1.py new file mode 100644 index 0000000..19b9aa3 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/TreeLike_1.py @@ -0,0 +1,26 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.TreeLike`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject + + +class TreeLike_1[T](BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.TreeLike`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + 'generic_params': {'T': []}, + } + + Children: dict[str, TreeLike_1[T]] = Field(default_factory=lambda: {}) + Values: list[T] = Field(default_factory=lambda: []) diff --git a/src/bhom_schema/FixtureAlpha_oM/Wrapper_1.py b/src/bhom_schema/FixtureAlpha_oM/Wrapper_1.py new file mode 100644 index 0000000..4e06c66 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/Wrapper_1.py @@ -0,0 +1,27 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Wrapper`1. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.BHoM.IBHoMObject import IBHoMObject +from bhom_schema._support import Polymorphic + + +class Wrapper_1[T: Polymorphic[IBHoMObject]](BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.Wrapper`1', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + 'generic_params': {'T': ['BH.oM.Base.IBHoMObject']}, + } + + Items: list[T] = Field(default_factory=lambda: []) diff --git a/src/bhom_schema/FixtureAlpha_oM/__init__.py b/src/bhom_schema/FixtureAlpha_oM/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bhom_schema/FixtureBeta_oM/Elements/UsesBoth.py b/src/bhom_schema/FixtureBeta_oM/Elements/UsesBoth.py new file mode 100644 index 0000000..26e0d9b --- /dev/null +++ b/src/bhom_schema/FixtureBeta_oM/Elements/UsesBoth.py @@ -0,0 +1,25 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureBeta.Elements.UsesBoth. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.PointLike import PointLike +from bhom_schema.FixtureBeta_oM.PointLike import PointLike as FixtureBeta_oM_PointLike + + +class UsesBoth(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureBeta.Elements.UsesBoth', + 'assembly': 'FixtureBeta_oM', + 'namespace': 'BH.oM.FixtureBeta.Elements', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + FromAlpha: PointLike | None = None + Local: FixtureBeta_oM_PointLike | None = None diff --git a/src/bhom_schema/FixtureBeta_oM/Elements/__init__.py b/src/bhom_schema/FixtureBeta_oM/Elements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bhom_schema/FixtureBeta_oM/PointLike.py b/src/bhom_schema/FixtureBeta_oM/PointLike.py new file mode 100644 index 0000000..533ebe5 --- /dev/null +++ b/src/bhom_schema/FixtureBeta_oM/PointLike.py @@ -0,0 +1,23 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureBeta.PointLike. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from bhom_schema.BHoM.IObject import IObject +from bhom_schema._support import SchemaModel + + +class PointLike(SchemaModel, IObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureBeta.PointLike', + 'assembly': 'FixtureBeta_oM', + 'namespace': 'BH.oM.FixtureBeta', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + X: float = 0.0 diff --git a/src/bhom_schema/FixtureBeta_oM/__init__.py b/src/bhom_schema/FixtureBeta_oM/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bhom_schema/__init__.py b/src/bhom_schema/__init__.py new file mode 100644 index 0000000..c2c9b8a --- /dev/null +++ b/src/bhom_schema/__init__.py @@ -0,0 +1,4 @@ +"""Python classes generated from the BHoM C# object model (oM).""" +from bhom_schema._version import __bhom_version__, __version__ + +__all__ = ["__bhom_version__", "__version__"] diff --git a/src/bhom_schema/_support/__init__.py b/src/bhom_schema/_support/__init__.py new file mode 100644 index 0000000..7f1a2ad --- /dev/null +++ b/src/bhom_schema/_support/__init__.py @@ -0,0 +1,9 @@ +"""Hand-written helpers shared by every generated module. Never overwritten by the generator.""" + +from bhom_schema._support.markers import ClrType, DisplayText, DynamicProperty, Quantity +from bhom_schema._support.color import Color +from bhom_schema._support.enums import BHoMEnum, BHoMFlags +from bhom_schema._support.polymorphic import Polymorphic +from bhom_schema._support.schema_model import SchemaModel + +__all__ = ["BHoMEnum", "BHoMFlags", "ClrType", "Color", "DisplayText", "DynamicProperty", "Polymorphic", "Quantity", "SchemaModel"] diff --git a/src/bhom_schema/_support/color.py b/src/bhom_schema/_support/color.py new file mode 100644 index 0000000..e963c46 --- /dev/null +++ b/src/bhom_schema/_support/color.py @@ -0,0 +1,10 @@ +"""Stand-in for ``System.Drawing.Color``.""" + +from __future__ import annotations +from pydantic import BaseModel + +class Color(BaseModel): + A: int = 0 + R: int = 0 + G: int = 0 + B: int = 0 diff --git a/src/bhom_schema/_support/enums.py b/src/bhom_schema/_support/enums.py new file mode 100644 index 0000000..a714338 --- /dev/null +++ b/src/bhom_schema/_support/enums.py @@ -0,0 +1,109 @@ +"""Enums written and read the way BHoM's JSON does: by member name, like "Centre". + +BHoM writes an enum as its name, except at top level or in an `object` property, where it writes the object +`{"_t": "System.Enum", "TypeName": {"_t": "System.Type", "Name": ""}, "Value": ""}`. Both are read; the name is written. +""" + +from __future__ import annotations + +from enum import Enum, IntFlag +from typing import Any + +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import PydanticCustomError, core_schema + +class NamedMembers: + """Mixin that gives a generated enum its BHoM JSON behaviour: names in and out, and the matching Pydantic schema and JSON Schema.""" + + @classmethod + def bhom_members(cls) -> dict[str, Any]: + """Maps each member name as C# spells it to the member; `None_` is "None", from the renamed members of the marker. + + Used to read names from JSON and to list the allowed names in the JSON Schema. Aliases (a named combination of flags) are included. + """ + renamed: dict[str, str] = getattr(cls, "__bhom__", {}).get("renamed_members", {}) + members: dict[str, Any] = getattr(cls, "__members__") + return {renamed.get(name, name): member for name, member in members.items()} + + def bhom_name(self) -> str: + """Returns the name BHoM writes for this value: the member's C# name, or the names of the set flags joined by ", " for a combination. + + The serializer of every generated enum field calls it. + """ + cls = type(self) + members = cls.bhom_members() + value: int = getattr(self, "value") + known: Any = getattr(cls, "_value2member_map_").get(value) + if known is not None: + return next(name for name, member in members.items() if member is known) + + parts = [name for name, member in members.items() if member.value & value and (member.value & (member.value - 1)) == 0] + return ", ".join(parts) if parts else str(value) + + @classmethod + def from_bhom(cls, value: Any) -> Any: + """Reads a member from what BHoM JSON holds: a member name, "A, B" for combined flags, BHoM's object form, a number, or a member itself. + + The validator of every generated enum field calls it. + """ + if isinstance(value, cls): + return value + + if isinstance(value, dict) and "Value" in value: + return cls.from_bhom(value["Value"]) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] + + if isinstance(value, bool): + raise PydanticCustomError("enum_value", "{value} is not a value of {enum}", {"value": str(value), "enum": cls.__name__}) + + if isinstance(value, int): + try: + return cls(value) # pyright: ignore[reportCallIssue] + except ValueError: + raise PydanticCustomError("enum_value", "{value} is not a value of {enum}", {"value": str(value), "enum": cls.__name__}) from None + + if isinstance(value, str): + members = cls.bhom_members() + tokens = [part.strip() for part in value.split(",")] + if len(tokens) > 1 and not issubclass(cls, IntFlag): + raise PydanticCustomError("enum_value", "{value} names several members, and {enum} is not a flags enum", {"value": value, "enum": cls.__name__}) + + result: Any = None + for token in tokens: + if token not in members: + raise PydanticCustomError("enum_value", "{value} is not a member of {enum}", {"value": token, "enum": cls.__name__}) + + result = members[token] if result is None else result | members[token] + + return result + + raise PydanticCustomError("enum_value", "expected a member name of {enum}", {"enum": cls.__name__}) + + @classmethod + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + """Builds the schema Pydantic uses for a field of this enum: read with `from_bhom`, written as the name with `bhom_name`.""" + return core_schema.no_info_plain_validator_function( + cls.from_bhom, serialization=core_schema.plain_serializer_function_ser_schema(lambda member: member.bhom_name(), return_schema=core_schema.str_schema())) + + @classmethod + def __get_pydantic_json_schema__(cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: + """Describes the enum as BHoM_JSONSchema does: a name from the list of members, or BHoM's object form holding the same name. + + A flags enum may combine names ("A, B"), so for those the names are only suggestions and any string is allowed. + """ + names = list(cls.bhom_members()) + name_schema: JsonSchemaValue = {"type": ["string", "null"]} if issubclass(cls, IntFlag) else {"type": ["string", "null"], "enum": names} + return {"anyOf": [name_schema, { + "type": ["object", "null"], + "properties": { + "_t": {"type": ["string", "null"], "const": "System.Enum"}, + "TypeName": {"type": ["object", "null"], "properties": {"_t": {"type": ["string", "null"], "const": "System.Type"}, "Name": {"type": ["string", "null"]}}}, + "Value": name_schema, + }, + }]} + +class BHoMEnum(NamedMembers, Enum): + """Base class of the generated enums.""" + +class BHoMFlags(NamedMembers, IntFlag): + """Base class of the generated enums that are C# [Flags] enums.""" diff --git a/src/bhom_schema/_support/markers.py b/src/bhom_schema/_support/markers.py new file mode 100644 index 0000000..ed71c3b --- /dev/null +++ b/src/bhom_schema/_support/markers.py @@ -0,0 +1,29 @@ +"""Plain metadata markers used inside ``typing.Annotated``. Pydantic ignores them at runtime.""" + +from __future__ import annotations +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ClrType: + """The original C# type of a field when it cannot be recovered from the Python annotation.""" + name: str + + +@dataclass(frozen=True) +class Quantity: + """The physical quantity (for example ``Length``) and SI unit (for example ``m``) of a field.""" + category: str + unit: str + + +@dataclass(frozen=True) +class DisplayText: + """The C# ``DisplayText`` attribute of a field.""" + text: str + + +@dataclass(frozen=True) +class DynamicProperty: + """Marks a field that is a C# ``DynamicProperty``.""" + pass diff --git a/src/bhom_schema/_support/polymorphic.py b/src/bhom_schema/_support/polymorphic.py new file mode 100644 index 0000000..2dc2e0c --- /dev/null +++ b/src/bhom_schema/_support/polymorphic.py @@ -0,0 +1,104 @@ +"""Fields typed with a C# interface or abstract class, written and read the way BHoM_JSONSchema describes them. + +A value of such a field is an object of one of the implementing classes. In JSON it carries `_t`, the C# class name, which picks the class. +The generator writes the implementers of every interface (and abstract class) into its `__bhom__` marker, as text, so nothing is imported until +a `_t` value names a class. +""" + +from __future__ import annotations + +import importlib +import threading +from collections.abc import Mapping +from typing import Annotated, Any, cast + +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import PydanticCustomError, core_schema + +ROOT_PACKAGE = "bhom_schema" +# How many interface descriptions are being written at this moment on this thread; see PolymorphicMarker.__get_pydantic_json_schema__ +_describing = threading.local() + +def implementers_of(interface: type) -> dict[str, str]: + """Returns the classes that implement an interface, as {C# class name: module path below bhom_schema}, from the `implementers` entry of its marker. + + `Polymorphic` reads it to know which `_t` values an interface field accepts; an interface with no entry accepts no JSON, only objects. + """ + marker: dict[str, Any] = getattr(interface, "__bhom__", {}) + return dict(marker.get("implementers", {})) + +def load_class(location: str) -> type: + """Imports the module of an implementing class and returns the class; the location is the module path below bhom_schema, optionally followed by :ClassName. + + Called only when a `_t` value names the class, so loading one class does not load the whole package. + """ + path, _, name = location.partition(":") + module = importlib.import_module(f"{ROOT_PACKAGE}.{path}") + return getattr(module, name or path.rsplit(".", 1)[-1]) + +class PolymorphicMarker: + """Metadata for `Polymorphic[...]`: makes Pydantic validate, save and describe an interface field the way BHoM_JSONSchema does.""" + + def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + """Builds the validation of an interface field: an object of the interface is accepted as it is, JSON is validated against the class its `_t` names. + + Pydantic calls it once per field. The interface is the annotated type; it is kept in the schema metadata for the JSON Schema. + """ + interface: type = source_type + + def validate(value: Any) -> Any: + """Accepts an object of the interface, or a mapping whose `_t` names one of the implementers and which is valid for that class alone.""" + if isinstance(value, interface): + return value + + if not isinstance(value, Mapping): + raise PydanticCustomError("polymorphic_type", "expected an object of {interface} or a JSON object with _t", {"interface": interface.__name__}) + + mapping = cast(Mapping[str, Any], value) + tag = mapping.get("_t") + if tag is None: + raise PydanticCustomError("polymorphic_tag_missing", "a value for {interface} needs _t, the name of its class", {"interface": interface.__name__}) + + implementers = implementers_of(interface) + if tag not in implementers: + raise PydanticCustomError("polymorphic_tag_unknown", "{tag} is not a class of {interface} in bhom_schema", {"tag": str(tag), "interface": interface.__name__}) + + # The error of the chosen class is the only one reported: it names the real problem + return getattr(load_class(implementers[tag]), "model_validate")(dict(mapping)) + + return core_schema.no_info_plain_validator_function(validate, metadata={"bhom_interface": interface}) + + def __get_pydantic_json_schema__(self, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: + """Describes an interface field as BHoM_JSONSchema does: `_t` is required and one of the implementers, and the class it names decides the rest. + + `model_json_schema()` uses it, which is what makes the JSON Schema of every class with an interface field (for example every BHoMObject) work. + """ + interface: type = schema["metadata"]["bhom_interface"] # pyright: ignore[reportTypedDictNotRequiredAccess] + implementers = implementers_of(interface) + description: JsonSchemaValue = {"required": ["_t"], "properties": {"_t": {"enum": list(implementers)}}} + + # An implementer can hold a field of an interface again, so describing every implementer inside every interface would never end: + # only the outermost interface lists the classes with their schemas, and an interface met inside them lists the allowed _t values. + depth: int = getattr(_describing, "depth", 0) + if depth > 0: + return description + + _describing.depth = depth + 1 + try: + choices: list[JsonSchemaValue] = [] + for tag, location in implementers.items(): + model_schema: core_schema.CoreSchema = getattr(load_class(location), "__pydantic_core_schema__") + choices.append({ + "if": {"properties": {"_t": {"type": "string", "const": tag}}, "required": ["_t"]}, + "then": handler(model_schema), + }) + finally: + _describing.depth = depth + + if choices: + description["allOf"] = choices + + return description + +type Polymorphic[T] = Annotated[T, PolymorphicMarker()] diff --git a/src/bhom_schema/_support/schema_model.py b/src/bhom_schema/_support/schema_model.py new file mode 100644 index 0000000..8d3e6c1 --- /dev/null +++ b/src/bhom_schema/_support/schema_model.py @@ -0,0 +1,64 @@ +"""Base class of every generated model: it reads and writes the JSON that BHoM_JSONSchema describes.""" + +from __future__ import annotations + +from typing import Any, cast + +from pydantic import BaseModel, ConfigDict, Field, SerializerFunctionWrapHandler, model_serializer, model_validator + +FRAGMENT_SET = "BH.oM.Base.FragmentSet" + +def type_name(model: type[BaseModel]) -> str: + """Returns the C# full name of a generated class, from its `__bhom__` marker, or the Python class name when there is none.""" + marker: dict[str, Any] = getattr(model, "__bhom__", {}) + return str(marker.get("clr_name", model.__name__)) + +def add_type_property(schema: dict[str, Any], model: type[BaseModel]) -> None: + """Adds the optional `_t` property to the JSON Schema of a class, fixed to its C# name, as every schema of BHoM_JSONSchema has it.""" + properties = cast(dict[str, Any], schema.setdefault("properties", {})) + properties["_t"] = {"type": ["string", "null"], "description": "Optional type disciminator.", "const": type_name(model)} + +class SchemaModel(BaseModel): + """Pydantic base of the generated classes. + + JSON follows BHoM_JSONSchema: every object is written with `_t`, its C# class name, and may carry `_bhomVersion`. A `_t` that is read + must name this class. Interfaces are plain ABC classes, so fields typed with them need an isinstance check. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_by_name=True, validate_by_alias=True, serialize_by_alias=True, json_schema_extra=add_type_property) + + bhom_version: str | None = Field(default=None, alias="_bhomVersion", description="Optional version of BHoM used as part of automatic versioning and schema upgrades.") + + @model_validator(mode="before") + @classmethod + def read_type_tag(cls, data: Any) -> Any: + """Checks and removes `_t` before a mapping is validated, and unwraps a FragmentSet written as an object. + + `_t` is optional, but when it is there it must be this class (the base name, for a generic class). BHoM writes a FragmentSet as + `{"_t": "BH.oM.Base.FragmentSet", "_v": [...]}` or as a plain array; the fields hold a list, so both end up as one. + """ + if not isinstance(data, dict): + return data + + values = {str(key): value for key, value in cast(dict[Any, Any], data).items()} + tag = values.pop("_t", None) + if tag is not None: + expected = type_name(cls) + if str(tag).split("`")[0] != expected.split("`")[0]: + raise ValueError(f"_t is {tag}, but this class is {expected}") + + for key, value in list(values.items()): + if isinstance(value, dict) and cast(dict[str, Any], value).get("_t") == FRAGMENT_SET: + values[key] = cast(dict[str, Any], value).get("_v") or [] + + return values + + @model_serializer(mode="wrap") + def write_type_tag(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + """Writes `_t` first, and leaves out `_bhomVersion` when it is not set, so the JSON matches what BHoM writes.""" + data = cast(dict[str, Any], handler(self)) + if data.get("_bhomVersion", 0) is None: + del data["_bhomVersion"] + + return {"_t": type_name(type(self)), **data} diff --git a/src/bhom_schema/_version.py b/src/bhom_schema/_version.py new file mode 100644 index 0000000..ae549e5 --- /dev/null +++ b/src/bhom_schema/_version.py @@ -0,0 +1,5 @@ +# Generated by BHoM_PythonSchema from the BHoM assemblies. Do not edit by hand. +# __bhom_version__ is the BHoM version (Major.Minor) of the classes; __version__ is the version of this package. + +__bhom_version__ = "10.0" +__version__ = "10.0.0.dev0"