From 5bceb5422695abf78d193c724319f18acd10c21c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:28:51 +0000 Subject: [PATCH 1/2] Quote a Python base that names the class being declared (closes #64) [patch] Python evaluates a base list as the class statement runs, so an interface written over its own implementer -- IVector0, which is how every generated ktsu.Semantics quantity is declared -- names something that does not exist yet and raises NameError on import. The module cannot be loaded at all. The declaration is not what is wrong: six targets write it correctly, and Python is the one that reads a base eagerly. The argument is now quoted, which is what typing takes as a forward reference and resolves once something asks, and is what a typing.Generic consumer writes for this case. Only the arguments are quoted, and only at the outermost level: a class may inherit from a subscripted generic holding a forward reference and may not inherit from a string, and one quoted argument already carries every name inside it. PythonGeneratedSourceImportsTests runs what the generator writes through a real interpreter, which is the Python equivalent of the four compile tests and the only way to catch the class of error where the text looks right and the interpreter disagrees. Reverting the fix fails it with the NameError above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSQCijNKMyTWktjbys6Yu3 --- CLAUDE.md | 8 ++ .../PythonGeneratedSourceImportsTests.cs | 122 ++++++++++++++++++ Coder.Test/Languages/ToolchainHarness.cs | 7 +- .../Languages/TypeDeclarationShapeTests.cs | 89 +++++++++++++ Coder/Languages/PythonGenerator.cs | 98 ++++++++++++-- 5 files changed, 311 insertions(+), 13 deletions(-) create mode 100644 Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d6d59e1..d165d46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,14 @@ source in seven target languages. The solution uses: whole a pointer to the member — to exactly one of them, and Go satisfies an interface structurally and so writes `var _ Contract = (*Type)(nil)`, an assertion the compiler checks rather than a declaration. A generator handed one list would be guessing which entry was the class. + Python folds the two back into the one list it has, and is the one target that has to do more than + spell them: a base list is an expression evaluated as the `class` statement runs, so a base naming + the class being declared — the self-type idiom, an interface written over its own implementer — + names something that does not exist yet and raises `NameError` on import. The argument is quoted, + which is what `typing` takes as a forward reference and resolves once something asks; + `from __future__ import annotations` is the other half of the idea and does not reach a base, which + is an expression rather than an annotation. `PythonGeneratedSourceImportsTests` loads what the + generator writes, because that is a class of error no test pinning the text can see. The three modifiers split along a line worth stating once: `IsRecord` and `IsReadOnly` are claims about the type — it compares by value, no member of it modifies it — so a target with no word for one writes it down, the same as `CompileTimeAssertion`, while Rust's `#[derive(Clone, Debug, diff --git a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs new file mode 100644 index 0000000..aa56fbe --- /dev/null +++ b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Runs what writes, with a real interpreter. +/// +/// +/// The other four targets that are checked by a toolchain are checked by compiling. Python has no +/// compiler, and the equivalent question is whether the module can be imported at all: a Python file +/// is a program that builds its own declarations, so a class statement runs when the module is loaded +/// and anything wrong with it is raised then. That is the whole class of error a test pinning the +/// text cannot see — the spelling looks right and the interpreter disagrees — and a base list naming +/// the class being declared is exactly one of them. +/// +/// The driver is written the way a consumer would be: it supplies the names the generated module +/// expects to find — the interface it implements, and the type variable it is written over — and then +/// runs the module against them. runpy is how a module is run with names already in its +/// namespace; an import cannot, and the AST has no way to declare a TypeVar or a +/// Generic for the file to carry its own. +/// +/// +/// The test is inconclusive rather than failing where no interpreter is on the path, which is the +/// honest result: nothing was run. +/// +/// +[TestClass] +public class PythonGeneratedSourceImportsTests +{ + /// + /// The consumer of the generated module, written the way a person would write one. + /// + /// + /// Every declaration the module makes is asked for afterwards, so a module that loads but declares + /// nothing fails here rather than passing quietly. Length is the self-type idiom and + /// Node the mutually-referential pair, which are the two shapes a base list can hold a name + /// that does not exist yet. + /// + private const string Driver = """ + import runpy + from typing import Generic, TypeVar + + T = TypeVar("T") + TSelf = TypeVar("TSelf") + + + class IVector0(Generic[TSelf, T]): + pass + + + class Visitor(Generic[TSelf]): + pass + + + module = runpy.run_path( + "exemplar.py", + init_globals={"IVector0": IVector0, "Visitor": Visitor, "T": T}, + ) + + assert module["Length"].__name__ == "Length" + assert module["Node"].__name__ == "Node" + + """; + + /// + /// Tests that a module whose classes are written over themselves can be loaded. + /// + [TestMethod] + public void GeneratedSource_Imports() + { + string? python = ToolchainHarness.FindOnPath("--version", "python3", "python"); + if (python is null) + { + Assert.Inconclusive("No Python interpreter on the path, so nothing was run."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "driver.py"), Driver); + File.WriteAllText( + Path.Combine(directory, "exemplar.py"), + new PythonGenerator().Generate(Exemplar())); + + (int exitCode, string output) = ToolchainHarness.Run(python, "driver.py", directory); + Assert.AreEqual( + 0, + exitCode, + $"Python could not load the generated module:{Environment.NewLine}{output}"); + }); + } + + /// + /// Builds a file whose declarations are each written over themselves. + /// + /// The file to generate. + private static SourceFile Exemplar() + { + SourceFile file = new("exemplar"); + file.HeaderComment.Add("Generated by Coder. Do not edit."); + + // The shape every generated ktsu.Semantics quantity has: an interface written over the type + // implementing it, so that the method it declares answers that type rather than the interface. + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0, T>")); + length.Members.Add(new VariableDeclaration("Value", "T")); + + // The same knot without generics of its own: a type and the thing that visits it, each named + // in the other's declaration. + ClassDeclaration node = new("Node"); + node.Interfaces.Add(TypeReference.Parse("Visitor")); + + file.Members.Add(length); + file.Members.Add(node); + + return file; + } +} diff --git a/Coder.Test/Languages/ToolchainHarness.cs b/Coder.Test/Languages/ToolchainHarness.cs index a9af2d2..48dd26c 100644 --- a/Coder.Test/Languages/ToolchainHarness.cs +++ b/Coder.Test/Languages/ToolchainHarness.cs @@ -5,7 +5,7 @@ namespace ktsu.Coder.Test.Languages; using System.Diagnostics; /// -/// Runs a real compiler over generated source. +/// Runs a real toolchain over generated source. /// /// /// Three of the generators here are checked by compiling what they write, because the rules they @@ -13,6 +13,11 @@ namespace ktsu.Coder.Test.Languages; /// about receivers, associated items and what a trait implementation owes its trait, and Go's about /// unused names, which it refuses rather than warns about. Everything those tests share about /// *running* a compiler is here, so that what is left in each of them is the language. +/// +/// Python is asked the same question by an interpreter rather than a compiler, having no compiler to +/// ask: a Python file builds its own declarations as it is loaded, so a declaration the language +/// refuses raises on import rather than failing to build. +/// /// internal static class ToolchainHarness { diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index 24840bd..f981b3a 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -55,6 +55,19 @@ private static ClassDeclaration Money() => IsReadOnly = true, }; + /// + /// A type implementing an interface written over the type itself, which is how an interface gives + /// a method the implementing type as its result — TSelf Create(T value) rather than + /// IVector0 Create(T value). + /// + /// The declaration. + private static ClassDeclaration SelfTyped() + { + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0, T>")); + return length; + } + private static string Generate(ILanguageGenerator generator, AstNode node) => generator.Generate(node); // ------------------------------------------------------------------ Interfaces @@ -138,6 +151,82 @@ public void Python_TakesThemAllAsBases() StringAssert.Contains(code, "class Widget(Control, Drawable, Clickable):"); } + /// + /// Python quotes the argument of a base that names the class being declared, because a base list + /// is an expression Python evaluates before the name exists. + /// + /// + /// The self-type idiom, which is how an interface hands a method the implementing type — every one + /// of the 212 generated quantities in ktsu.Semantics is declared this way. Unquoted, the + /// module raises NameError on import rather than misbehaving later, so this is the + /// difference between a file that can be loaded and one that cannot. + /// + [TestMethod] + public void Python_QuotesABaseArgumentNamingTheClassBeingDeclared() + { + string code = Generate(new PythonGenerator(), SelfTyped()); + + StringAssert.Contains(code, "class Length(IVector0[\"Length[T]\", T]):"); + } + + /// + /// The same for a pair that refer to each other, which needs no generics of its own. + /// + [TestMethod] + public void Python_QuotesABaseArgumentThatIsTheClassItself() + { + ClassDeclaration node = new("Node"); + node.Interfaces.Add(TypeReference.Parse("Visitor")); + + string code = Generate(new PythonGenerator(), node); + + StringAssert.Contains(code, "class Node(Visitor[\"Node\"]):"); + } + + /// + /// An argument holding the class further down is quoted whole, which is the one place the quote + /// can go: a second pair inside the first would end the string rather than nest. + /// + [TestMethod] + public void Python_QuotesTheWholeArgumentWhenTheClassIsNestedInIt() + { + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0>, T>")); + + string code = Generate(new PythonGenerator(), length); + + StringAssert.Contains(code, "class Length(IVector0[\"Wrapper[Length[T]]\", T]):"); + } + + /// + /// An argument naming anything else is left alone: a forward reference is what a name that does + /// not exist yet needs, and every other name is one Python can already resolve. + /// + [TestMethod] + public void Python_LeavesABaseArgumentNamingSomethingElseUnquoted() + { + ClassDeclaration button = new("Button"); + button.Interfaces.Add(TypeReference.Parse("Handler")); + + string code = Generate(new PythonGenerator(), button); + + StringAssert.Contains(code, "class Button(Handler[Event]):"); + Assert.DoesNotContain("\"", code, "Python quoted a base that names nothing being declared."); + } + + /// + /// The other targets write the same declaration verbatim, because the declaration is not what is + /// wrong with it: Python is the one target that reads a base list eagerly. + /// + [TestMethod] + public void OtherTargets_WriteASelfTypedInterfaceVerbatim() + { + StringAssert.Contains( + Generate(new CSharpGenerator(), SelfTyped()), "class Length : IVector0, T>"); + StringAssert.Contains( + Generate(new CppGenerator(), SelfTyped()), "class Length : public IVector0, T>"); + } + /// /// JavaScript extends one thing and has no interfaces, so it writes down what it cannot say. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 74d505e..2e60f6e 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -408,10 +408,11 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod // Python inherits from as many things as it is given and has no separate notion of an // interface, so the base and the interfaces are one list of bases -- which is what the // abstract base classes in the standard library already are. + string? declaring = classDecl.Name; string[] bases = [ - .. classDecl.BaseType is TypeReference baseType ? (string[])[PythonTypeFromGenericType(baseType)] : [], - .. classDecl.Interfaces.Select(PythonTypeFromGenericType), + .. classDecl.BaseType is TypeReference baseType ? (string[])[PythonBaseFromGenericType(baseType, declaring)] : [], + .. classDecl.Interfaces.Select(implemented => PythonBaseFromGenericType(implemented, declaring)), ]; if (bases.Length > 0) @@ -678,16 +679,7 @@ protected override void GenerateParameter(Parameter parameter, CodeBlocker code, /// private static string PythonTypeFromGenericType(TypeReference type) { - string name = type.Name.ToLowerInvariant() switch - { - "int" => "int", - "string" => "str", - "bool" => "bool", - "float" => "float", - "double" => "float", - "void" => "None", - _ => type.Name - }; + string name = PythonTypeName(type); string spelled = type.TypeArguments.Count == 0 ? name @@ -698,6 +690,88 @@ private static string PythonTypeFromGenericType(TypeReference type) return type.IsArray ? $"list[{spelled}]" : spelled; } + /// + /// Maps a type's name to the Python spelling of it. + /// + /// The type whose name to map. + /// The name Python knows it by. + private static string PythonTypeName(TypeReference type) => type.Name.ToLowerInvariant() switch + { + "int" => "int", + "string" => "str", + "bool" => "bool", + "float" => "float", + "double" => "float", + "void" => "None", + _ => type.Name + }; + + /// + /// Spells a base of a class, quoting any argument that names the class being declared. + /// + /// The base to spell. + /// The name of the class the base list belongs to. + /// The Python source for it. + /// + /// A base list is an expression and Python evaluates it as the class statement is run, so a + /// base naming the class being declared is a name that does not exist yet: the self-type idiom — + /// IVector0<TSelf, T>, the interface that hands a method the implementing type — makes + /// the module raise NameError on import rather than misbehave later. Nothing about the + /// declaration is wrong; Python is the one target that reads a base eagerly. + /// + /// A quoted argument is the language's own answer, and the one typing consumers write for + /// exactly this case: typing takes a string as a forward reference and resolves it when + /// something asks, by which time the class exists. from __future__ import annotations is + /// the other half of the idea and does not reach here — it defers *annotations*, and a base is an + /// expression. + /// + /// + /// Only the arguments are quoted, never the base itself. A class may inherit from a subscripted + /// generic holding a forward reference and may not inherit from a string, and quoting is at the + /// outermost argument alone for the same reason: one quoted argument carries every name inside it, + /// and a second pair of quotes within the first would end the string rather than nest. + /// + /// + private static string PythonBaseFromGenericType(TypeReference type, string? declaring) + { + if (declaring is null || type.TypeArguments.Count == 0 || !NamesType(type.TypeArguments, declaring)) + { + return PythonTypeFromGenericType(type); + } + + IEnumerable arguments = type.TypeArguments.Select(argument => + NamesType(argument, declaring) + ? $"\"{PythonTypeFromGenericType(argument)}\"" + : PythonTypeFromGenericType(argument)); + + string spelled = $"{PythonTypeName(type)}[{string.Join(", ", arguments)}]"; + + return type.IsArray ? $"list[{spelled}]" : spelled; + } + + /// + /// Asks whether a type names another anywhere in it. + /// + /// The type to look through. + /// The name being looked for. + /// True when the name is the type's own or any of its arguments'. + /// + /// Through the arguments rather than at the top alone, because a base is as readily written over a + /// type holding the class — IVector0<Wrapper<Length<T>>, T> — as over the + /// class itself, and Python evaluates all of it at once either way. + /// + private static bool NamesType(TypeReference type, string name) => + string.Equals(type.Name, name, StringComparison.Ordinal) || NamesType(type.TypeArguments, name); + + /// + /// Asks whether any of several types names another anywhere in it. + /// + /// The types to look through. + /// The name being looked for. + /// True when any of them names it. + private static bool NamesType(IEnumerable types, string name) => + types.Any(type => NamesType(type, name)); + /// /// /// A constant is emitted as an ordinary assignment: Python has no constant declaration, and the From 5b879130e0be238d610829a77a82a42f3a5bf526 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:43:09 +0000 Subject: [PATCH 2/2] Take the assertion Sonar names for the tests this PR adds [patch] MSTEST0046 on each of the six new assertions: Assert.Contains rather than StringAssert.Contains, which is the form this file already uses for the negative it asserts. The pre-existing StringAssert calls are left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSQCijNKMyTWktjbys6Yu3 --- .../Languages/TypeDeclarationShapeTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index f981b3a..3e4ac5d 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -166,7 +166,7 @@ public void Python_QuotesABaseArgumentNamingTheClassBeingDeclared() { string code = Generate(new PythonGenerator(), SelfTyped()); - StringAssert.Contains(code, "class Length(IVector0[\"Length[T]\", T]):"); + Assert.Contains("class Length(IVector0[\"Length[T]\", T]):", code); } /// @@ -180,7 +180,7 @@ public void Python_QuotesABaseArgumentThatIsTheClassItself() string code = Generate(new PythonGenerator(), node); - StringAssert.Contains(code, "class Node(Visitor[\"Node\"]):"); + Assert.Contains("class Node(Visitor[\"Node\"]):", code); } /// @@ -195,7 +195,7 @@ public void Python_QuotesTheWholeArgumentWhenTheClassIsNestedInIt() string code = Generate(new PythonGenerator(), length); - StringAssert.Contains(code, "class Length(IVector0[\"Wrapper[Length[T]]\", T]):"); + Assert.Contains("class Length(IVector0[\"Wrapper[Length[T]]\", T]):", code); } /// @@ -210,7 +210,7 @@ public void Python_LeavesABaseArgumentNamingSomethingElseUnquoted() string code = Generate(new PythonGenerator(), button); - StringAssert.Contains(code, "class Button(Handler[Event]):"); + Assert.Contains("class Button(Handler[Event]):", code); Assert.DoesNotContain("\"", code, "Python quoted a base that names nothing being declared."); } @@ -221,10 +221,10 @@ public void Python_LeavesABaseArgumentNamingSomethingElseUnquoted() [TestMethod] public void OtherTargets_WriteASelfTypedInterfaceVerbatim() { - StringAssert.Contains( - Generate(new CSharpGenerator(), SelfTyped()), "class Length : IVector0, T>"); - StringAssert.Contains( - Generate(new CppGenerator(), SelfTyped()), "class Length : public IVector0, T>"); + Assert.Contains( + "class Length : IVector0, T>", Generate(new CSharpGenerator(), SelfTyped())); + Assert.Contains( + "class Length : public IVector0, T>", Generate(new CppGenerator(), SelfTyped())); } ///