From b61489c0a3b568197bb95b4387aef7abd459e600 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:28:50 +0000 Subject: [PATCH] fix: build a write-only Python property from its setter alone [patch] PythonGenerator wrote `@name.setter` for every writable property, but that decorator is an attribute of the property object the `@property` getter creates. A property with no getter therefore named something that did not exist, and defining the class raised NameError. A write-only property is now written as a `_set_name` function followed by `name = property(fset=_set_name)`, which needs no getter. A new test loads the generated module with python3, sets the property, and checks that reading it raises AttributeError. Fixes #113 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017USYdQkEy3VLP48Dd4JadP --- .../PythonGeneratedSourceImportsTests.cs | 52 +++++++++++++++++++ Coder/Languages/PythonGenerator.cs | 12 +++++ 2 files changed, 64 insertions(+) diff --git a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs index 005b372..3cdd8ce 100644 --- a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs +++ b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs @@ -196,6 +196,58 @@ assert isinstance(shapes.Point.zero(), shapes.Point) }); } + /// + /// Tests that a class with a property that can be written but not read can be loaded and set. + /// + /// + /// @level.setter names the property a getter made, so a property with no getter needs + /// another spelling or the class statement raises NameError. + /// + [TestMethod] + public void WriteOnlyProperty_ImportsAndSets() + { + string? python = ToolchainHarness.FindOnPath("--version", "python3", "python"); + if (python is null) + { + Assert.Inconclusive("No Python interpreter on the path, so nothing was run."); + return; + } + + ClassDeclaration sink = new("Sink"); + PropertyDeclaration level = new("level", "int") { HasGetter = false, HasSetter = true }; + CallExpression record = new("record"); + record.Arguments.Add(new VariableReference("value")); + level.SetterBody.Add(new ExpressionStatement(record)); + sink.Members.Add(level); + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "sink.py"), new PythonGenerator().Generate(sink)); + File.WriteAllText( + Path.Combine(directory, "driver.py"), + """ + import runpy + + recorded = [] + module = runpy.run_path("sink.py", init_globals={"record": recorded.append}) + + instance = module["Sink"]() + instance.level = 3 + assert recorded == [3], recorded + + try: + instance.level + except AttributeError: + pass + else: + raise AssertionError("a write-only property could be read") + """); + + (int exitCode, string output) = ToolchainHarness.Run(python, "driver.py", directory); + Assert.AreEqual(0, exitCode, $"Python rejected the generated module:{Environment.NewLine}{output}"); + }); + } + /// /// Builds a file whose declarations are each written over themselves. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 55462d6..49dc9ca 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -131,6 +131,18 @@ protected override void GeneratePropertyDeclaration(PropertyDeclaration declarat WriteAccessorBody(declaration.GetterBody, code); } + if (declaration.CanWrite && !declaration.CanRead) + { + // `@name.setter` is an attribute of the property the getter made, so with no getter there + // is nothing for the decorator to name and the class statement raises NameError. The + // property is built from the setter alone instead. + string setter = $"_set_{name}"; + code.Write($"def {setter}(self, value{annotation}) -> None:"); + WriteAccessorBody(declaration.SetterBody, code); + code.WriteLine($"{name} = property(fset={setter})"); + return; + } + if (declaration.CanWrite) { code.WriteLine($"@{name}.setter");