From 72f4bf02018735225aacb0c985ca30a11b2acd85 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Sat, 5 Sep 2026 13:56:32 -0400 Subject: [PATCH 01/10] Require compile-time constant arguments to time() (#208) --- src/fpy/macros.py | 3 +++ test/fpy/test_time_seqs.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/fpy/macros.py b/src/fpy/macros.py index 71a46b2..a2acd6d 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -369,6 +369,9 @@ def generate_write_to_port_llvm(builder, args): ("timeContext", U8, FpyValue(U8, 0)), ], lambda n, c, t: [], # placeholder - const eval handles this + # every argument must be a compile-time constant: time() is always + # evaluated at compile time and has no runtime lowering + const_arg_indices=frozenset({0, 1, 2}), ) MACROS: dict[str, BuiltinFuncSymbol] = { diff --git a/test/fpy/test_time_seqs.py b/test/fpy/test_time_seqs.py index a1d9827..762417e 100644 --- a/test/fpy/test_time_seqs.py +++ b/test/fpy/test_time_seqs.py @@ -1250,3 +1250,25 @@ def test_time_function_u32_overflow(self, fprime_test_api): t: Fw.Time = time("2200-01-01T00:00:00Z") """ assert_compile_failure(fprime_test_api, seq) + + def test_time_function_runtime_time_base(self, fprime_test_api): + """time() is evaluated at compile time, so a timeBase that is only + known at run time is rejected.""" + seq = """ +b: TimeBase = TimeBase.TB_NONE +t: Fw.Time = time("2025-01-01T00:00:00Z", b) +""" + assert_compile_failure( + fprime_test_api, seq, match="must be a compile-time constant" + ) + + def test_time_function_runtime_time_context(self, fprime_test_api): + """time() is evaluated at compile time, so a timeContext that is only + known at run time is rejected.""" + seq = """ +x: U8 = 1 +t: Fw.Time = time("2025-01-01T00:00:00Z", TimeBase.TB_NONE, x) +""" + assert_compile_failure( + fprime_test_api, seq, match="must be a compile-time constant" + ) From f28f1155741a1041280202cb0104bea858cda554 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Sat, 5 Sep 2026 13:58:34 -0400 Subject: [PATCH 02/10] Give each runtime index in an element store its own bounds-check label (#184) --- src/fpy/codegen_fpybc.py | 7 ++++- test/fpy/test_types_and_constructors.py | 34 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index 669f699..4fb49bc 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -1203,8 +1203,13 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState): # Emit code to compute each dynamic offset component # (idx * elem_size) and sum them together on the stack. for i, (idx_expr, parent_type) in enumerate(dynamic_components): + # the bounds check's label is named after the node it is + # given; the index expression is unique per component, the + # assignment is not dirs.extend( - self._emit_array_element_offset(node, idx_expr, parent_type, state) + self._emit_array_element_offset( + idx_expr, idx_expr, parent_type, state + ) ) if i > 0: dirs.append(IntAddDirective()) diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index e3e047e..f05e2ba 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -401,6 +401,40 @@ def pick(idx: I8) -> U32: assert pick(0) == 10 assert pick(1) == 20 +""" + assert_run_success(fprime_test_api, seq) + + def test_assign_array_element_two_runtime_indices(self, fprime_test_api): + """A store whose access chain has two runtime indices: each index + needs its own bounds check.""" + seq = """ +a: Ref.TooManyChoices = Ref.TooManyChoices( \\ + Ref.ManyChoices(Ref.Choice.ONE, Ref.Choice.ONE), \\ + Ref.ManyChoices(Ref.Choice.ONE, Ref.Choice.ONE)) +i: I64 = 1 +j: I64 = 0 +a[i][j] = Ref.Choice.TWO +assert a[1][0] == Ref.Choice.TWO +assert a[1][1] == Ref.Choice.ONE +assert a[0][0] == Ref.Choice.ONE +""" + assert_run_success(fprime_test_api, seq) + + def test_assign_global_array_element_two_runtime_indices_in_function( + self, fprime_test_api + ): + """The same two-runtime-index store, from a function writing a global.""" + seq = """ +g: Ref.TooManyChoices = Ref.TooManyChoices( \\ + Ref.ManyChoices(Ref.Choice.ONE, Ref.Choice.ONE), \\ + Ref.ManyChoices(Ref.Choice.ONE, Ref.Choice.ONE)) + +def set_choice(i: I64, j: I64, c: Ref.Choice): + g[i][j] = c + +set_choice(0, 1, Ref.Choice.RED) +assert g[0][1] == Ref.Choice.RED +assert g[0][0] == Ref.Choice.ONE """ assert_run_success(fprime_test_api, seq) From b60cb1d0c08f86232f0a0b537bdf9be8bcba1f07 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Sat, 5 Sep 2026 14:05:31 -0400 Subject: [PATCH 03/10] Reject constant floats that overflow to infinity when narrowed to F32/F64 (#110) --- src/fpy/semantics.py | 6 +++++- test/fpy/test_types_and_constructors.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index c8f19a6..24c1b76 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -4,6 +4,7 @@ from decimal import Decimal import decimal import itertools +import math from pathlib import Path import struct from typing import Union @@ -2356,7 +2357,10 @@ def const_convert_type( rounded_value = CalculateConstExprValues._round_float_to_type( coerced_value, to_type ) - if rounded_value is None: + # a value too large for the type either fails to pack or, past + # the double range, silently becomes infinite; there is no + # literal for an infinite value, so both are out of range + if rounded_value is None or not math.isfinite(rounded_value): state.err( f"{raw_val} is out of range for type {to_type.display_name}", node, diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index e3e047e..9227177 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -571,6 +571,31 @@ def test_calling_variable_should_fail_gracefully(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) +class TestNonFiniteFloatConstants: + """A constant float that does not fit its finite float type is a compile + error, never a silent infinity.""" + + def test_f64_literal_overflow(self, fprime_test_api): + seq = "x: F64 = 1e999\n" + assert_compile_failure(fprime_test_api, seq, match="out of range for type F64") + + def test_f32_literal_overflow(self, fprime_test_api): + seq = "x: F32 = 1e999\n" + assert_compile_failure(fprime_test_api, seq, match="out of range for type F32") + + def test_f64_folded_overflow(self, fprime_test_api): + seq = "x: F64 = 1e308 * 10.0\n" + assert_compile_failure(fprime_test_api, seq, match="out of range for type F64") + + def test_f64_folded_overflow_of_typed_operands(self, fprime_test_api): + seq = "x: F64 = F64(1e308) * F64(10.0)\n" + assert_compile_failure(fprime_test_api, seq, match="out of range for type F64") + + def test_f64_max_is_in_range(self, fprime_test_api): + seq = "x: F64 = 1.7976931348623157e308\nassert x > 1e308\n" + assert_run_success(fprime_test_api, seq) + + class TestStringTypes: def test_string_eq(self, fprime_test_api): From f4cb433f98bac281d7a8f56dfb5029c7540743e9 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Sat, 5 Sep 2026 14:09:59 -0400 Subject: [PATCH 04/10] Fold non-numeric equality by serialized bytes to match the runtime (#171) --- src/fpy/semantics.py | 18 ++++++++++-- test/fpy/test_types_and_constructors.py | 38 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index c8f19a6..2278d3d 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2672,6 +2672,18 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): state.const_expr_values[node] = expr_value + @staticmethod + def _const_equal(lhs: FpyValue, rhs: FpyValue) -> bool: + """== of two constants. Numbers compare by value; a non-numeric operand + (struct, array, enum, time) compares by serialized bytes, which is + what the runtime compares (spec "Equality semantics"), so that folding + cannot change a comparison's answer: 0.0 and -0.0 are equal as + numbers but not as struct members.""" + if lhs.type.is_numerical: + return lhs.val == rhs.val + assert lhs.type == rhs.type, (lhs.type, rhs.type) + return lhs.serialize() == rhs.serialize() + def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): # Check if both left-hand side (lhs) and right-hand side (rhs) are constants lhs_value: FpyValue = state.const_expr_values.get(node.lhs) @@ -2689,6 +2701,8 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): return # Both sides are constants, evaluate the operation if the operator is supported + lhs_const = lhs_value + rhs_const = rhs_value # get the actual pythonic value from the fpy type lhs_value = lhs_value.val rhs_value = rhs_value.val @@ -2736,9 +2750,9 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): folded_value = lhs_value <= rhs_value # Equality Checking elif node.op == BinaryStackOp.EQUAL: - folded_value = lhs_value == rhs_value + folded_value = self._const_equal(lhs_const, rhs_const) elif node.op == BinaryStackOp.NOT_EQUAL: - folded_value = lhs_value != rhs_value + folded_value = not self._const_equal(lhs_const, rhs_const) else: # missing an operation assert False, node.op diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index e3e047e..a2f054f 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -473,6 +473,44 @@ def test_const_fold_array_neq(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + def test_const_fold_struct_eq_negative_zero_member(self, fprime_test_api): + """Folded aggregate equality compares serialized bytes like the + runtime does, so a 0.0 member and a -0.0 member are not equal.""" + seq = """ +if Ref.SignalPair(1.0, 0.0) == Ref.SignalPair(1.0, -0.0): + exit(1) +""" + assert_run_success(fprime_test_api, seq) + + def test_const_fold_struct_neq_negative_zero_member(self, fprime_test_api): + seq = """ +if Ref.SignalPair(1.0, 0.0) != Ref.SignalPair(1.0, -0.0): + exit(0) +exit(1) +""" + assert_run_success(fprime_test_api, seq) + + def test_const_fold_array_eq_agrees_with_runtime(self, fprime_test_api): + """The folded and the runtime answers to the same comparison agree.""" + seq = """ +a: Ref.SignalSet = Ref.SignalSet(1.0, 2.0, 3.0, 0.0) +b: Ref.SignalSet = Ref.SignalSet(1.0, 2.0, 3.0, -0.0) +if a == b: + exit(2) +if Ref.SignalSet(1.0, 2.0, 3.0, 0.0) == Ref.SignalSet(1.0, 2.0, 3.0, -0.0): + exit(1) +""" + assert_run_success(fprime_test_api, seq) + + def test_const_fold_numeric_negative_zero_is_equal(self, fprime_test_api): + """Numbers, including aggregate members read out, still compare + numerically: 0.0 == -0.0.""" + seq = """ +assert 0.0 == -0.0 +assert Ref.SignalPair(1.0, 0.0).value == Ref.SignalPair(1.0, -0.0).value +""" + assert_run_success(fprime_test_api, seq) + def test_runtime_array_equality(self, fprime_test_api): """Array equality with runtime (non-const) operands.""" seq = """ From 727363bae26329e25bf375343e21b6d3e602f8eb Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 8 Sep 2026 10:43:19 -0400 Subject: [PATCH 05/10] Merge pull request #213 from fprime-community/185-param-array-store-offset Push the constant part of a dynamic store offset as a signed value --- src/fpy/codegen_fpybc.py | 9 +++--- test/fpy/test_types_and_constructors.py | 37 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index 4fb49bc..cc9abe5 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -1215,13 +1215,14 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState): dirs.append(IntAddDirective()) # Add the constant part: base variable's frame offset + - # accumulated constant field offsets. + # accumulated constant field offsets. Signed, because a parameter + # sits below the frame start at a negative offset. const_part = base_frame_offset + field_const_offset - dirs.append(PushValDirective(FpyValue(U64, const_part).serialize())) + dirs.append(PushValDirective(FpyValue(I64, const_part).serialize())) dirs.append(IntAddDirective()) - # and now convert the u64 back into the SignedStackSizeType that store expects - dirs.extend(self.convert_numeric_type(U64, SignedStackSizeType)) + # and now convert the i64 back into the SignedStackSizeType that store expects + dirs.extend(self.convert_numeric_type(I64, SignedStackSizeType)) # now that the frame offset is pushed, use it to store into the frame if use_abs: diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index 4466904..89d25e1 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -404,6 +404,43 @@ def pick(idx: I8) -> U32: """ assert_run_success(fprime_test_api, seq) + def test_assign_param_array_element_runtime_index(self, fprime_test_api): + """A store through a runtime index into an array parameter. Parameters + live at negative frame offsets, and the store must land on the right + element without touching its neighbors.""" + seq = """ +def f(a: Ref.FpyExampleArray, i: I64) -> U32: + a[i] = 7 + assert a[0] == 1 + assert a[2] == 3 + return a[i] + +arr: Ref.FpyExampleArray = Ref.FpyExampleArray(1, 2, 3) +k: I64 = 1 +assert f(arr, k) == 7 +""" + assert_run_success(fprime_test_api, seq) + + def test_assign_param_array_element_member_runtime_index(self, fprime_test_api): + """A store into a struct member of a runtime-indexed element of an + array-of-struct parameter.""" + seq = """ +def set_value(pairs: Ref.SignalPairSet, i: I64, v: F32) -> F32: + pairs[i].value = v + assert pairs[i].time == 5.0 + assert pairs[1].value == 4.0 + return pairs[i].value + +p: Ref.SignalPairSet = Ref.SignalPairSet( \\ + Ref.SignalPair(1.0, 2.0), \\ + Ref.SignalPair(3.0, 4.0), \\ + Ref.SignalPair(5.0, 6.0), \\ + Ref.SignalPair(7.0, 8.0)) +idx: I64 = 2 +assert set_value(p, idx, 99.0) == 99.0 +""" + assert_run_success(fprime_test_api, seq) + def test_assign_array_element_two_runtime_indices(self, fprime_test_api): """A store whose access chain has two runtime indices: each index needs its own bounds check.""" From 3bfd3bc5103f4e639b5a518cb3026dbf79bbaec7 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 8 Sep 2026 10:46:12 -0400 Subject: [PATCH 06/10] Reserve the float libcall names so script symbols named pow, log or fmod do not hijack them on wasm (#191) (#217) --- src/fpy/wasm_host.py | 24 +++++++++++++++---- test/fpy/test_functions.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/fpy/wasm_host.py b/src/fpy/wasm_host.py index 844780e..1f368b6 100644 --- a/src/fpy/wasm_host.py +++ b/src/fpy/wasm_host.py @@ -55,15 +55,31 @@ def _declare_host_func( return fn +def _declare_libcalls(module: ir.Module) -> None: + """Claim the names of the float libcalls LLVM emits for llvm.pow, llvm.log + and frem, before any script symbol is named. Script functions and + variables are emitted under their own names, uniqued against the module + (``get_unique_name``), so without these declarations a script function or + variable named ``pow`` would be the symbol the libcall binds to.""" + from llvmlite import ir + + f64 = ir.DoubleType() + ir.Function(module, ir.FunctionType(f64, [f64, f64]), name="pow") + ir.Function(module, ir.FunctionType(f64, [f64]), name="log") + ir.Function(module, ir.FunctionType(f64, [f64, f64]), name="fmod") + + def declare_host_imports(module: ir.Module) -> None: """Declare the full expected host interface on *module*; emit sites look - the functions up in ``module.globals``. The float libcalls - (env.pow/fmod/log) are deliberately absent: LLVM materializes those itself - when lowering llvm.pow/llvm.log/frem, and they stay under wasm-ld's - default import module "env".""" + the functions up in ``module.globals``. Also declares the float libcalls + (env.pow/fmod/log) that LLVM materializes itself when lowering + llvm.pow/llvm.log/frem, only so that their names are taken: those stay + under wasm-ld's default import module "env" and no emit site calls + them.""" from llvmlite import ir error_code_type = ErrorCodeType.llvm_type + _declare_libcalls(module) # exit(code) ends the whole sequence. It never returns to wasm (the host # unwinds the interpreter); noreturn lets LLVM drop the dead code after it. diff --git a/test/fpy/test_functions.py b/test/fpy/test_functions.py index 7dfa4fb..e7c9c44 100644 --- a/test/fpy/test_functions.py +++ b/test/fpy/test_functions.py @@ -313,6 +313,55 @@ def noop(): """ assert_compile_failure(fprime_test_api, seq) + def test_function_named_pow_does_not_replace_exponent(self, fprime_test_api): + """A script function may be named after a float libcall (pow, fmod) + without becoming the implementation of the operator that libcall + backs.""" + seq = """ +def pow(a: F64, b: F64) -> F64: + return 77.0 + +y: F64 = 2.0 +z: F64 = y ** 3.0 +assert z == 8.0 +assert pow(1.0, 1.0) == 77.0 +""" + assert_run_success(fprime_test_api, seq) + + def test_function_named_fmod_does_not_replace_modulus(self, fprime_test_api): + seq = """ +def fmod(a: F64, b: F64) -> F64: + return 77.0 + +y: F64 = 7.5 +r: F64 = y % 2.0 +assert r == 1.5 +assert fmod(1.0, 1.0) == 77.0 +""" + assert_run_success(fprime_test_api, seq) + + def test_function_named_log_does_not_replace_ln(self, fprime_test_api): + seq = """ +def log(x: F64) -> F64: + return 77.0 + +y: F64 = 4.0 +l: F64 = ln(y) +assert l > 1.386 and l < 1.387 +assert log(1.0) == 77.0 +""" + assert_run_success( + fprime_test_api, seq, expected_warnings={WarningType.SHADOW_CALLABLE} + ) + + def test_variable_named_pow_does_not_break_exponent(self, fprime_test_api): + seq = """ +pow: F64 = 2.0 +z: F64 = pow ** 2.0 +assert z == 4.0 +""" + assert_run_success(fprime_test_api, seq) + def test_call_embedded_in_bare_expression(self, fprime_test_api): """A bare expression statement whose top-level node isn't itself side-effecting (an == comparison) still embeds a call that is; the From 086a5153b699642f61b21cfba86e3a4bcd3d8036 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 8 Sep 2026 10:47:59 -0400 Subject: [PATCH 07/10] Take time()'s microseconds from the parsed timestamp so they are exact (#222) (#235) Co-authored-by: Claude Opus 5 (1M context) --- src/fpy/semantics.py | 6 ++++-- test/fpy/test_time_seqs.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 87c78a1..843ee12 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2271,9 +2271,11 @@ def _parse_time_string( dt = dt.replace(tzinfo=timezone.utc) timestamp = dt.timestamp() - # Split into seconds and microseconds + # Split into seconds and microseconds. The microseconds come + # from the parsed value, not from the float timestamp, whose + # resolution is coarser than a microsecond at modern dates. seconds = int(timestamp) - useconds = int((timestamp - seconds) * 1_000_000) + useconds = dt.microsecond # Validate ranges for U32 if seconds < 0: diff --git a/test/fpy/test_time_seqs.py b/test/fpy/test_time_seqs.py index 762417e..e3f4fb9 100644 --- a/test/fpy/test_time_seqs.py +++ b/test/fpy/test_time_seqs.py @@ -1156,6 +1156,17 @@ def test_time_function_with_microseconds(self, fprime_test_api): t: Fw.Time = time("2000-01-01T00:00:00.123456Z") assert t.seconds == 946684800 assert t.useconds == 123456 +""" + assert_run_success(fprime_test_api, seq) + + def test_time_function_microseconds_exact(self, fprime_test_api): + """The microsecond field is exact, not one low from a float + subtraction that loses resolution at modern timestamps.""" + seq = """ +t: Fw.Time = time("2025-12-19T14:30:00.000007Z") +assert t.useconds == 7 +u: Fw.Time = time("2026-09-08T00:00:00.000001Z") +assert u.useconds == 1 """ assert_run_success(fprime_test_api, seq) From 0ffb2a788e111256c1e5e8378cb7ee361270812f Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 8 Sep 2026 10:49:33 -0400 Subject: [PATCH 08/10] Report a domain error when const folding ** yields a complex number (#228) (#236) Co-authored-by: Claude Opus 5 (1M context) --- src/fpy/semantics.py | 6 ++++++ test/fpy/test_arithmetic.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 843ee12..b25a2a8 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2726,6 +2726,12 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): folded_value = lhs_value / rhs_value elif node.op == BinaryStackOp.EXPONENT: folded_value = lhs_value**rhs_value + if isinstance(folded_value, complex): + # float ** float returns a complex number for a negative + # base and a fractional exponent, where the Decimal path + # raises decimal.InvalidOperation + state.err("Domain error", node) + return elif node.op == BinaryStackOp.FLOOR_DIVIDE: # Floor toward -inf (Python `//`), matching the runtime backends. if isinstance(lhs_value, int) and isinstance(rhs_value, int): diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index d3c176b..9efb469 100644 --- a/test/fpy/test_arithmetic.py +++ b/test/fpy/test_arithmetic.py @@ -56,6 +56,24 @@ def test_const_complex_pow(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + def test_const_complex_pow_float_operands(self, fprime_test_api): + """A negative base with a fractional exponent is a domain error even + when an operand has already been folded to a Python float, where + ``float ** float`` returns a complex number instead of raising.""" + seq = """ +x: F64 = F64(-8.0) ** 0.5 +""" + + assert_compile_failure(fprime_test_api, seq, match="Domain error") + + def test_const_complex_pow_float_exponent(self, fprime_test_api): + """Same when it is the exponent that carries the float type.""" + seq = """ +y: F64 = (-2.0) ** F64(0.5) +""" + + assert_compile_failure(fprime_test_api, seq, match="Domain error") + def test_very_large_const_pow(self, fprime_test_api): seq = """ 10.0 ** 1000 From 0e4c929c52fc3e090094dc398c9ab83d3abec6a8 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Wed, 9 Sep 2026 13:47:21 -0400 Subject: [PATCH 09/10] Allow emitting stdout (#189) * Allow emitting stdout * Add ipc and capsysbinary to the spell check dictionary --- .github/actions/spelling/expect.txt | 2 + README.md | 2 +- src/fpy/main.py | 117 ++++++++++++++------- test/fpy/test_main.py | 151 +++++++++++++++++++++++++++- 4 files changed, 231 insertions(+), 41 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index b31e9dd..449872c 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -13,6 +13,7 @@ Breslav calcsize calle capsys +capsysbinary cbranch Ccsds Cdh @@ -77,6 +78,7 @@ ifelifelse IH ine ip +ipc itrunc kinda lalr diff --git a/README.md b/README.md index 8ab04ad..d26e6c5 100644 --- a/README.md +++ b/README.md @@ -724,7 +724,7 @@ Some useful compiler flags are: ### `fprime-fpy-cmd` -Compiles a single line of Fpy source (one command with constant arguments) and uplinks it to a running GDS, e.g. `fprime-fpy-cmd 'Ref.seqDisp.RUN_ARGS("seq.bin", NO_WAIT)' -d dict.json`. Uplinks over ZMQ by default; pass `--tcp-addr host:port` to use TCP instead. A `RUN_ARGS` line that passes sequence arguments needs `--seq-map` to locate the called sequence's `.fpy` source. +Compiles a single line of Fpy source (one command with constant arguments) and emits it, e.g. `fprime-fpy-cmd 'Ref.seqDisp.RUN_ARGS("seq.bin", NO_WAIT)' -d dict.json`. The `--emit` flag selects how: `zmq` (uplink to a running GDS over ZMQ, the default; address set by `--zmq-addr`), `tcp` (uplink to the GDS TCP server; address set by `--tcp-addr host:port`), or `stdout` (write the binary command packet to stdout). ### `fprime-fpy-asm` diff --git a/src/fpy/main.py b/src/fpy/main.py index 10bf88d..b5df1bf 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import getpass from importlib.metadata import version from pathlib import Path import socket @@ -391,30 +392,44 @@ def disassemble_main(args: list[str] = None): FW_PACKET_COMMAND = 0 +# Matches the fprime-gds default uplink URL (see fprime_gds.executables.cli). +DEFAULT_ZMQ_ADDR = f"ipc:///tmp/fprime-server-in-{getpass.getuser()}" +DEFAULT_TCP_ADDR = "127.0.0.1:50050" + def build_command_packet(cmd_opcode: int, args: bytes) -> bytes: - """Build an F Prime command packet for ZMQ transport. + """Build an F Prime command packet. - Format: size(4B) + descriptor_type(2B) + opcode(4B) + args - The size field covers descriptor_type + opcode + args and is stripped - by the GDS ZmqGround receiver before forwarding to the framing protocol. + Format: descriptor_type(2B) + opcode(4B) + args The descriptor_type is a ComCfg.Apid (U16), not a U32. """ descriptor_type = struct.pack(">H", FW_PACKET_COMMAND) opcode = struct.pack(">I", cmd_opcode) - payload = descriptor_type + opcode + args - size = struct.pack(">I", len(payload)) - return size + payload + return descriptor_type + opcode + args + + +def frame_packet_for_gds(packet: bytes) -> bytes: + """Prefix a packet with its size(4B), as the GDS transports expect. + + The size field is stripped by the GDS receiver (ZmqGround or the TCP + server) before forwarding to the framing protocol. + """ + return struct.pack(">I", len(packet)) + packet def send_command_zmq(cmd_opcode: int, args: bytes, zmq_addr: str): """Send a pre-serialized command to the GDS via ZMQ. - The ZMQ message format is: b"FSW" + command_packet + The ZMQ message format is: b"FSW" + size(4B) + command_packet """ - import zmq + try: + import zmq + except ImportError as e: + raise RuntimeError( + "pyzmq is required for --emit zmq (pip install pyzmq)" + ) from e - packet = build_command_packet(cmd_opcode, args) + packet = frame_packet_for_gds(build_command_packet(cmd_opcode, args)) context = zmq.Context() sock = context.socket(zmq.PUB) @@ -436,7 +451,7 @@ def send_command_tcp(cmd_opcode: int, args: bytes, tcp_addr: str, tcp_port: int) 2. Send command: b"A5A5 FSW " + b"ZZZZ" + size(4B) + payload where the ZZZZ frame is what TcpServerFramerDeframer expects. """ - packet = build_command_packet(cmd_opcode, args) + packet = frame_packet_for_gds(build_command_packet(cmd_opcode, args)) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: @@ -471,17 +486,29 @@ def cmd_main(args: list[str] = None): ) _add_seq_map_argument(arg_parser) _add_time_base_argument(arg_parser) + arg_parser.add_argument( + "--emit", + choices=["zmq", "tcp", "stdout"], + default="zmq", + help=( + "How to emit the command: 'zmq' (send to the GDS uplink over ZMQ, " + "the default), 'tcp' (send to the GDS TCP server), 'stdout' (write " + "the binary command packet to stdout: packet descriptor (U16, 0 " + "for commands) + command opcode (U32) + serialized arguments, all " + "big-endian)" + ), + ) arg_parser.add_argument( "--zmq-addr", type=str, - default="ipc:///tmp/fprime-server-in", - help="ZMQ address for the GDS uplink (default: ipc:///tmp/fprime-server-in)", + default=None, + help=f"ZMQ address of the GDS uplink, with --emit zmq (default: {DEFAULT_ZMQ_ADDR})", ) arg_parser.add_argument( "--tcp-addr", type=str, default=None, - help="TCP server address as host:port (e.g. 127.0.0.1:50050). If provided, use TCP instead of ZMQ.", + help=f"TCP server address as host:port, with --emit tcp (default: {DEFAULT_TCP_ADDR})", ) if args is not None: @@ -495,6 +522,34 @@ def cmd_main(args: list[str] = None): print(e, file=sys.stderr) sys.exit(1) + if parsed_args.zmq_addr is not None and parsed_args.emit != "zmq": + print("--zmq-addr is only valid with --emit zmq", file=sys.stderr) + sys.exit(1) + if parsed_args.tcp_addr is not None and parsed_args.emit != "tcp": + print("--tcp-addr is only valid with --emit tcp", file=sys.stderr) + sys.exit(1) + + tcp_host = None + tcp_port = None + if parsed_args.emit == "tcp": + tcp_addr = parsed_args.tcp_addr or DEFAULT_TCP_ADDR + parts = tcp_addr.rsplit(":", 1) + if len(parts) != 2: + print( + f"Invalid --tcp-addr format: {tcp_addr!r} (expected host:port)", + file=sys.stderr, + ) + sys.exit(1) + tcp_host = parts[0] + try: + tcp_port = int(parts[1]) + except ValueError: + print( + f"Invalid port in --tcp-addr: {parts[1]!r}", + file=sys.stderr, + ) + sys.exit(1) + source = parsed_args.source if not source.endswith("\n"): source += "\n" @@ -552,33 +607,25 @@ def cmd_main(args: list[str] = None): directive = cmd_directives[0] - if parsed_args.tcp_addr is not None: - parts = parsed_args.tcp_addr.rsplit(":", 1) - if len(parts) != 2: - print( - f"Invalid --tcp-addr format: {parsed_args.tcp_addr!r} (expected host:port)", - file=sys.stderr, - ) - sys.exit(1) - tcp_host = parts[0] - try: - tcp_port = int(parts[1]) - except ValueError: - print( - f"Invalid port in --tcp-addr: {parts[1]!r}", - file=sys.stderr, - ) - sys.exit(1) - print(f"Sending {source.strip()} via TCP {parsed_args.tcp_addr}") + if parsed_args.emit == "stdout": + sys.stdout.buffer.write( + build_command_packet(directive.cmd_opcode, directive.args) + ) + sys.stdout.buffer.flush() + elif parsed_args.emit == "tcp": + print(f"Sending {source.strip()} via TCP {tcp_host}:{tcp_port}") try: send_command_tcp(directive.cmd_opcode, directive.args, tcp_host, tcp_port) except Exception as e: print(f"Failed to send command: {e}", file=sys.stderr) sys.exit(1) - else: - print(f"Sending {source.strip()} via {parsed_args.zmq_addr}") + elif parsed_args.emit == "zmq": + zmq_addr = parsed_args.zmq_addr or DEFAULT_ZMQ_ADDR + print(f"Sending {source.strip()} via {zmq_addr}") try: - send_command_zmq(directive.cmd_opcode, directive.args, parsed_args.zmq_addr) + send_command_zmq(directive.cmd_opcode, directive.args, zmq_addr) except Exception as e: print(f"Failed to send command: {e}", file=sys.stderr) sys.exit(1) + else: + assert False, parsed_args.emit diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index dc71377..5dbe633 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -632,17 +632,158 @@ def test_cmd_main_zmq_addr(monkeypatch, capsys): def test_build_command_packet(): - """Command packet has correct wire format: size(4B) + descriptor(2B) + opcode(4B) + args.""" + """Command packet has correct wire format: descriptor(2B) + opcode(4B) + args.""" import struct packet = fpy_main.build_command_packet(0x10006001, b"\x01\x02\x03") - # size = 2 (descriptor) + 4 (opcode) + 3 (args) = 9 - expected_size = struct.pack(">I", 9) expected_descriptor = struct.pack(">H", 0) # FW_PACKET_COMMAND = 0 expected_opcode = struct.pack(">I", 0x10006001) expected_args = b"\x01\x02\x03" - assert ( - packet == expected_size + expected_descriptor + expected_opcode + expected_args + assert packet == expected_descriptor + expected_opcode + expected_args + + +def test_frame_packet_for_gds(): + """The GDS transport frame prefixes the packet with its size(4B).""" + import struct + + framed = fpy_main.frame_packet_for_gds(b"\x01\x02\x03") + + assert framed == struct.pack(">I", 3) + b"\x01\x02\x03" + + +def _patch_compile_chain(monkeypatch, directive): + """Stub cmd_main's compile chain to yield the given directive.""" + monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, + "get_base_compile_state", + lambda dictionary, seq_maps=None, **kwargs: "STATE", ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", lambda state: ([directive], []) + ) + + +def test_cmd_main_emit_stdout(monkeypatch, capsysbinary): + """--emit stdout writes the binary command packet to stdout, nothing else.""" + directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xab\xcd") + _patch_compile_chain(monkeypatch, directive) + + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "--emit", + "stdout", + ] + ) + + assert capsysbinary.readouterr().out == fpy_main.build_command_packet( + 0x10006001, b"\xab\xcd" + ) + + +def test_cmd_main_emit_tcp(monkeypatch, capsys): + """--emit tcp sends via the TCP server, defaulting the address.""" + directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xab") + _patch_compile_chain(monkeypatch, directive) + + sent = {} + + def fake_send(cmd_opcode, args, host, port): + sent.update(cmd_opcode=cmd_opcode, args=args, host=host, port=port) + + monkeypatch.setattr(fpy_main, "send_command_tcp", fake_send) + + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "--emit", + "tcp", + ] + ) + + assert sent == { + "cmd_opcode": 0x10006001, + "args": b"\xab", + "host": "127.0.0.1", + "port": 50050, + } + assert "Sending" in capsys.readouterr().out + + +def test_cmd_main_emit_tcp_explicit_addr(monkeypatch, capsys): + """--tcp-addr overrides the default TCP address.""" + directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") + _patch_compile_chain(monkeypatch, directive) + + sent = {} + monkeypatch.setattr( + fpy_main, + "send_command_tcp", + lambda o, a, host, port: sent.update(host=host, port=port), + ) + + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "--emit", + "tcp", + "--tcp-addr", + "192.168.1.2:60000", + ] + ) + + assert sent == {"host": "192.168.1.2", "port": 60000} + + +@pytest.mark.parametrize( + "extra_args", + [ + ["--tcp-addr", "127.0.0.1:50050"], + ["--emit", "stdout", "--tcp-addr", "127.0.0.1:50050"], + ["--emit", "tcp", "--zmq-addr", "ipc:///tmp/x"], + ["--emit", "stdout", "--zmq-addr", "ipc:///tmp/x"], + ], +) +def test_cmd_main_addr_flag_requires_matching_emit(monkeypatch, capsys, extra_args): + """An address flag with a mismatched --emit is an error.""" + with pytest.raises(SystemExit) as exc: + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + *extra_args, + ] + ) + + assert exc.value.code == 1 + assert "is only valid with --emit" in capsys.readouterr().err + + +def test_cmd_main_invalid_tcp_addr(monkeypatch, capsys): + """A --tcp-addr without a port is rejected before compiling.""" + with pytest.raises(SystemExit) as exc: + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "--emit", + "tcp", + "--tcp-addr", + "localhost", + ] + ) + + assert exc.value.code == 1 + assert "Invalid --tcp-addr format" in capsys.readouterr().err From 58ab951ba202ce99a847284ac5fd493fed3db365 Mon Sep 17 00:00:00 2001 From: "alex.mariano" Date: Fri, 18 Sep 2026 11:22:55 -0500 Subject: [PATCH 10/10] Add Fw CmdREsponse CLEARED --- src/fpy/types.py | 1 + test/fpy/RefTopologyDictionary.json | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/fpy/types.py b/src/fpy/types.py index 99cd671..efb8cee 100644 --- a/src/fpy/types.py +++ b/src/fpy/types.py @@ -720,6 +720,7 @@ class PrmDef: "FORMAT_ERROR": 3, "EXECUTION_ERROR": 4, "BUSY": 5, + "CLEARED": 6, }, rep_type=U8, ) diff --git a/test/fpy/RefTopologyDictionary.json b/test/fpy/RefTopologyDictionary.json index 89e9cbb..f3afd92 100644 --- a/test/fpy/RefTopologyDictionary.json +++ b/test/fpy/RefTopologyDictionary.json @@ -1899,6 +1899,11 @@ "name" : "BUSY", "value" : 5, "annotation" : "Component busy" + }, + { + "name" : "CLEARED", + "value" : 6, + "annotation" : "Command tracking was cleared before the command completed" } ], "default" : "Fw.CmdResponse.OK",