Skip to content

Commit 2da9f31

Browse files
pirapiraclaude
andcommitted
Complete Phase 3c: attr assignment, string formatting, with statement, raise from
- Attribute access on dict-objects falls through to check dict entries (for class dicts) - Attribute assignment (obj.attr = value) and augmented (obj.attr += 1) on dicts - String % formatting: %s, %d, %i, %f, %r, %x, %o, %% - String .format() method: {}, {0}, {1}, {{, }} - Context managers (with statement) with __enter__/__exit__ protocol - Exception chaining (raise X from Y) evaluates and validates cause - 17 new test assertions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c6db57 commit 2da9f31

4 files changed

Lines changed: 263 additions & 11 deletions

File tree

LeanPython/Interpreter/Eval.lean

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,16 @@ partial def getAttributeValue (obj : Value) (attr : String) : InterpM Value := d
436436
| .list _ =>
437437
if knownListMethods.contains attr then return .boundMethod obj attr
438438
else throwAttributeError s!"'list' object has no attribute '{attr}'"
439-
| .dict _ =>
439+
| .dict ref =>
440440
if knownDictMethods.contains attr then return .boundMethod obj attr
441-
else throwAttributeError s!"'dict' object has no attribute '{attr}'"
441+
else do
442+
-- Check dict entries as attributes (for class/object dicts)
443+
let pairs ← heapGetDict ref
444+
for (k, v) in pairs do
445+
match k with
446+
| .str s => if s == attr then return v
447+
| _ => pure ()
448+
throwAttributeError s!"'dict' object has no attribute '{attr}'"
442449
| .set _ =>
443450
if knownSetMethods.contains attr then return .boundMethod obj attr
444451
else throwAttributeError s!"'set' object has no attribute '{attr}'"
@@ -548,7 +555,22 @@ partial def assignToTarget (target : Expr) (value : Value) : InterpM Unit := do
548555
| _ => pure ()
549556
for i in [:after.length] do
550557
assignToTarget after[i]! items[items.size - after.length + i]!
551-
| .attribute _obj _attr _ => throwNotImplemented "attribute assignment"
558+
| .attribute obj attr _ => do
559+
let objVal ← evalExpr obj
560+
match objVal with
561+
| .dict ref => do
562+
let pairs ← heapGetDict ref
563+
let key := Value.str attr
564+
let mut newPairs := pairs
565+
let mut found := false
566+
for i in [:pairs.size] do
567+
if pairs[i]!.1 == key then
568+
newPairs := newPairs.set! i (key, value)
569+
found := true
570+
break
571+
if !found then newPairs := newPairs.push (key, value)
572+
heapSetDict ref newPairs
573+
| _ => throwAttributeError s!"'{typeName objVal}' object attribute '{attr}' is read-only"
552574
| .starred inner _ => assignToTarget inner value
553575
| _ => throwRuntimeError (.runtimeError "invalid assignment target")
554576

@@ -699,7 +721,15 @@ partial def execStmt (s : Stmt) : InterpM Unit := do
699721
let classVal ← allocDict entries
700722
setVariable cd.name classVal
701723

702-
| .raise_ exprOpt _cause _ => do
724+
| .raise_ exprOpt cause _ => do
725+
-- Evaluate cause if present (for validation; chaining info not stored yet)
726+
if let some causeExpr := cause then
727+
let causeVal ← evalExpr causeExpr
728+
match causeVal with
729+
| .exception _ _ => pure ()
730+
| .none => pure ()
731+
| .builtin _ => pure ()
732+
| _ => throwTypeError "exception cause must be None or derive from BaseException"
703733
match exprOpt with
704734
| some e => do
705735
let v ← evalExpr e
@@ -783,7 +813,74 @@ partial def execStmt (s : Stmt) : InterpM Unit := do
783813
modify fun st => { st with activeException := none }
784814
| some other => do execStmts finally_; throw other
785815

786-
| .with_ _items _body _ => throwNotImplemented "with statements"
816+
| .with_ items body _ => do
817+
-- Evaluate context managers and call __enter__
818+
let mut managers : List (Value × Value) := []
819+
for item in items do
820+
let mgr ← evalExpr item.contextExpr
821+
-- Try to call __enter__ on the manager
822+
let entered ← match mgr with
823+
| .dict ref => do
824+
let pairs ← heapGetDict ref
825+
let mut enterFn : Option Value := none
826+
for (k, v) in pairs do
827+
match k with
828+
| .str s => if s == "__enter__" then enterFn := some v
829+
| _ => pure ()
830+
match enterFn with
831+
| some fn => callValueDispatch fn [mgr] []
832+
| none => pure mgr
833+
| _ => pure mgr
834+
if let some target := item.optionalVars then
835+
assignToTarget target entered
836+
managers := managers ++ [(mgr, entered)]
837+
-- Execute body with cleanup
838+
let mut bodyError : Option Signal := none
839+
try execStmts body
840+
catch
841+
| sig@(.error _) => bodyError := some sig
842+
| other => do
843+
-- For control flow signals (return/break), still call __exit__ then re-throw
844+
for (mgr, _) in managers.reverse do
845+
match mgr with
846+
| .dict ref => do
847+
let pairs ← heapGetDict ref
848+
for (k, v) in pairs do
849+
match k with
850+
| .str s =>
851+
if s == "__exit__" then do
852+
let _ ← callValueDispatch v [mgr, .none, .none, .none] []
853+
| _ => pure ()
854+
| _ => pure ()
855+
throw other
856+
-- Call __exit__ on each manager in reverse order
857+
let mut suppressed := false
858+
for (mgr, _) in managers.reverse do
859+
match mgr with
860+
| .dict ref => do
861+
let pairs ← heapGetDict ref
862+
let mut exitFn : Option Value := none
863+
for (k, v) in pairs do
864+
match k with
865+
| .str s => if s == "__exit__" then exitFn := some v
866+
| _ => pure ()
867+
match exitFn with
868+
| some fn =>
869+
let exitArgs := match bodyError with
870+
| none => [mgr, .none, .none, .none]
871+
| some (.error e) =>
872+
let excType := Value.str (runtimeErrorTypeName e)
873+
let excVal := Value.exception (runtimeErrorTypeName e) (runtimeErrorMessage e)
874+
[mgr, excType, excVal, .none]
875+
| _ => [mgr, .none, .none, .none]
876+
let result ← callValueDispatch fn exitArgs []
877+
if ← isTruthy result then suppressed := true
878+
| none => pure ()
879+
| _ => pure ()
880+
-- Re-raise body error if not suppressed
881+
match bodyError with
882+
| some sig => if !suppressed then throw sig
883+
| none => pure ()
787884
| .import_ _aliases _ => throwNotImplemented "import statements"
788885
| .importFrom _ _aliases _ _ => throwNotImplemented "import-from statements"
789886

@@ -1253,7 +1350,53 @@ partial def callStrMethod (s : String) (method : String) (args : List Value)
12531350
let padding := String.ofList (List.replicate (w - s.length) '0')
12541351
return .str (padding ++ s)
12551352
| _ => throwTypeError "zfill() takes 1 argument"
1256-
| "format" => return .str s
1353+
| "format" => do
1354+
-- Python str.format(): supports {}, {0}, {1}, {{, }}
1355+
let chars := s.toList
1356+
let mut result : List Char := []
1357+
let mut i := 0
1358+
let mut autoIdx := 0
1359+
while i < chars.length do
1360+
let c := chars[i]!
1361+
if c == '{' then
1362+
if i + 1 < chars.length && chars[i + 1]! == '{' then
1363+
result := result ++ ['{']
1364+
i := i + 2
1365+
else
1366+
-- Find closing }
1367+
let mut j := i + 1
1368+
while j < chars.length && chars[j]! != '}' do j := j + 1
1369+
if j >= chars.length then
1370+
throwValueError "Single '{' encountered in format string"
1371+
let fieldContent := String.ofList (chars.drop (i + 1) |>.take (j - i - 1))
1372+
-- Strip format spec after ':'
1373+
let fieldName := match fieldContent.splitOn ":" with
1374+
| name :: _ => name
1375+
| [] => ""
1376+
let argVal ←
1377+
if fieldName.isEmpty then do
1378+
if autoIdx >= args.length then throwIndexError "Replacement index out of range for positional args tuple"
1379+
let v := args[autoIdx]!
1380+
autoIdx := autoIdx + 1
1381+
pure v
1382+
else match fieldName.toNat? with
1383+
| some idx =>
1384+
if idx >= args.length then throwIndexError s!"Replacement index {idx} out of range for positional args tuple"
1385+
pure args[idx]!
1386+
| none => throwKeyError s!"'{fieldName}'"
1387+
let valStr ← valueToStr argVal
1388+
result := result ++ valStr.toList
1389+
i := j + 1
1390+
else if c == '}' then
1391+
if i + 1 < chars.length && chars[i + 1]! == '}' then
1392+
result := result ++ ['}']
1393+
i := i + 2
1394+
else
1395+
throwValueError "Single '}' encountered in format string"
1396+
else
1397+
result := result ++ [c]
1398+
i := i + 1
1399+
return .str (String.ofList result)
12571400
| "count" =>
12581401
match args with
12591402
| [.str sub] => return .int (stringCount s sub)

LeanPython/Runtime/Ops.lean

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,73 @@ partial def valueRepr (v : Value) : InterpM String :=
208208

209209
end
210210

211+
-- ============================================================
212+
-- String % formatting
213+
-- ============================================================
214+
215+
/-- Python-style `%` string formatting. Supports %s, %d, %i, %f, %r, %x, %o, %%. -/
216+
partial def formatPercent (fmt : String) (args : Array Value) : InterpM String := do
217+
let chars := fmt.toList
218+
let mut result : List Char := []
219+
let mut i := 0
220+
let mut argIdx := 0
221+
while i < chars.length do
222+
let c := chars[i]!
223+
if c == '%' then
224+
if i + 1 < chars.length then
225+
let spec := chars[i + 1]!
226+
match spec with
227+
| '%' => result := result ++ ['%']; i := i + 2
228+
| 's' =>
229+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
230+
let s ← valueToStr args[argIdx]!
231+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
232+
| 'r' =>
233+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
234+
let s ← valueRepr args[argIdx]!
235+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
236+
| 'd' | 'i' =>
237+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
238+
let s := match args[argIdx]! with
239+
| .int n => toString n
240+
| .bool b => toString (if b then 1 else 0)
241+
| .float f => toString f.toUInt64.toNat
242+
| v => Value.toStr v
243+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
244+
| 'f' =>
245+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
246+
let s := match args[argIdx]! with
247+
| .float f => toString f
248+
| .int n => toString (Float.ofInt n)
249+
| v => Value.toStr v
250+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
251+
| 'x' =>
252+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
253+
let s := match args[argIdx]! with
254+
| .int n =>
255+
let hexChars := Nat.toDigits 16 n.natAbs
256+
let hexStr := if hexChars.isEmpty then "0" else String.ofList hexChars
257+
if n < 0 then "-" ++ hexStr else hexStr
258+
| v => Value.toStr v
259+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
260+
| 'o' =>
261+
if argIdx >= args.size then throwTypeError "not enough arguments for format string"
262+
let s := match args[argIdx]! with
263+
| .int n =>
264+
let octChars := Nat.toDigits 8 n.natAbs
265+
let octStr := if octChars.isEmpty then "0" else String.ofList octChars
266+
if n < 0 then "-" ++ octStr else octStr
267+
| v => Value.toStr v
268+
result := result ++ s.toList; argIdx := argIdx + 1; i := i + 2
269+
| _ =>
270+
-- Unknown specifier, pass through
271+
result := result ++ ['%', spec]; i := i + 2
272+
else
273+
result := result ++ ['%']; i := i + 1
274+
else
275+
result := result ++ [c]; i := i + 1
276+
return String.ofList result
277+
211278
-- ============================================================
212279
-- Membership test (for `in` operator)
213280
-- ============================================================
@@ -335,9 +402,12 @@ partial def evalBinOp (op : BinOp) (left right : Value) : InterpM Value := do
335402
| .int a, .int b =>
336403
if b == 0 then throwZeroDivision "integer division or modulo by zero"
337404
else return .int (Int.fmod a b)
338-
| .str fmt, _ => do
339-
-- Basic % string formatting (stub)
340-
return .str fmt
405+
| .str fmt, right => do
406+
let args := match right with
407+
| .tuple arr => arr
408+
| v => #[v]
409+
let result ← formatPercent fmt args
410+
return .str result
341411
| _, _ =>
342412
match toFloat left, toFloat right with
343413
| some a, some b =>

LeanPythonTest/Interpreter.lean

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,42 @@ private def assertPyError (source errSubstr : String) : IO Unit := do
318318
#eval assertPy "print(\"hello_world\".upper())\n" "HELLO_WORLD\n"
319319
#eval assertPy "print(\"a_b_c\".split(\"_\"))\n" "['a', 'b', 'c']\n"
320320
#eval assertPy "print(\"test_string\".startswith(\"test\"))\n" "True\n"
321+
322+
-- ============================================================
323+
-- Attribute access and assignment on dict-objects (class dicts)
324+
-- ============================================================
325+
326+
#eval assertPy "class Foo:\n x = 10\nprint(Foo.x)\n" "10\n"
327+
#eval assertPy "class Foo:\n x = 10\nFoo.x = 20\nprint(Foo.x)\n" "20\n"
328+
#eval assertPy "class Foo:\n x = 10\nFoo.y = 99\nprint(Foo.y)\n" "99\n"
329+
330+
-- Augmented attribute assignment
331+
#eval assertPy "class C:\n count = 0\nC.count += 5\nprint(C.count)\n" "5\n"
332+
333+
-- ============================================================
334+
-- String % formatting
335+
-- ============================================================
336+
337+
#eval assertPy "print(\"hello %s\" % \"world\")\n" "hello world\n"
338+
#eval assertPy "print(\"%d + %d = %d\" % (1, 2, 3))\n" "1 + 2 = 3\n"
339+
#eval assertPy "print(\"val=%d\" % 42)\n" "val=42\n"
340+
#eval assertPy "print(\"100%%\" % ())\n" "100%\n"
341+
#eval assertPy "print(\"%x\" % 255)\n" "ff\n"
342+
#eval assertPy "print(\"%o\" % 8)\n" "10\n"
343+
344+
-- ============================================================
345+
-- String .format() method
346+
-- ============================================================
347+
348+
#eval assertPy "print(\"{} {}\".format(\"hello\", \"world\"))\n" "hello world\n"
349+
#eval assertPy "print(\"{0} {1}\".format(\"a\", \"b\"))\n" "a b\n"
350+
#eval assertPy "print(\"{1} {0}\".format(\"a\", \"b\"))\n" "b a\n"
351+
#eval assertPy "print(\"x={{y}}\".format())\n" "x={y}\n"
352+
#eval assertPy "print(\"{} is {}\".format(42, True))\n" "42 is True\n"
353+
354+
-- ============================================================
355+
-- Exception chaining (raise X from Y)
356+
-- ============================================================
357+
358+
#eval assertPy "try:\n raise ValueError(\"x\") from TypeError(\"y\")\nexcept ValueError as e:\n print(\"caught\")\n" "caught\n"
359+
#eval assertPy "try:\n raise ValueError(\"x\") from None\nexcept ValueError as e:\n print(\"ok\")\n" "ok\n"

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,8 +768,8 @@ For non-deterministic tests, structural equivalence is checked.
768768
| 0 | Project scaffolding | Done |
769769
| 1 | Lexer | Done |
770770
| 2 | Parser | Done |
771-
| 3 | Core interpreter (expressions, types) | In progress |
772-
| 4 | Control flow and functions | Not started |
771+
| 3 | Core interpreter (expressions, types) | Done |
772+
| 4 | Control flow and functions | In progress |
773773
| 5 | Object model (classes, inheritance) | Not started |
774774
| 6 | Module system | Not started |
775775
| 7a | Stdlib: core utilities | Not started |

0 commit comments

Comments
 (0)