From 94163fe46898546030f16d066b654a8620f77034 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Wed, 1 Jul 2026 01:18:29 -0400 Subject: [PATCH 01/10] Warning system --- src/fpy/error.py | 125 +++++++++++++++-------------- src/fpy/main.py | 32 ++++++++ src/fpy/semantics.py | 3 +- src/fpy/state.py | 36 +++++++-- src/fpy/test_helpers.py | 38 ++++++--- src/fpy/warnings.py | 60 ++++++++++++++ test/fpy/test_logging.py | 10 +-- test/fpy/test_main.py | 30 +++---- test/fpy/test_seq_calling.py | 2 +- test/fpy/test_warnings.py | 128 ++++++++++++++++++++++++++++++ test/spacewasm_runner/src/main.rs | 4 +- 11 files changed, 369 insertions(+), 99 deletions(-) create mode 100644 src/fpy/warnings.py create mode 100644 test/fpy/test_warnings.py diff --git a/src/fpy/error.py b/src/fpy/error.py index 974b614..e5f6390 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -73,6 +73,72 @@ def __init__(self, msg: str, node=None): super().__init__(msg) +def format_diagnostic(msg: str, node, stack_trace: str = "", color=Colors.red) -> str: + """Render a source-located diagnostic (error or warning). + + *color* is the Colors.* function used to highlight the message and the + caret -- red for errors, yellow for warnings. *node* may be an AST node, a + Token, or None (file-level diagnostic with no source snippet).""" + + stack_trace_optional = f"{stack_trace}\n" if debug and stack_trace else "" + file_name_str = file_name if file_name is not None else "" + + if node is None: + return f"{stack_trace_optional}{Colors.cyan(file_name_str)}: {Colors.bold(color(msg))}" + + meta = node if isinstance(node, Token) else node.meta + + source_start_line = meta.line - 1 - COMPILER_ERROR_CONTEXT_LINE_COUNT + source_start_line = max(0, source_start_line) + # end_line can be None for $END token + end_line = meta.end_line if meta.end_line is not None else meta.line + source_end_line = end_line - 1 + COMPILER_ERROR_CONTEXT_LINE_COUNT + source_end_line = min(len(input_lines), source_end_line) + + # this is the list of all the src lines we will display + source_to_display: list[str] = input_lines[ + source_start_line : source_end_line + 1 + ] + + # reserve this much space for the line numbers + # add two extra spaces for the caret to display multiline errors on lhs + line_number_space = 6 if source_end_line < 998 else 10 + + node_lines = end_line - meta.line + + # prefix all the lines with the prefix and line number + # right justified line number, then a |, then the line + # also if this is a multiline error, highlight the lines that errored with a > + source_to_display = [ + ( + ("> " if line_idx in range(meta.line - 1, end_line - 1) else "") + + str(source_start_line + line_idx + 1) + ).rjust(line_number_space) + + " | " + + line + for line_idx, line in enumerate(source_to_display) + ] + + if node_lines > 1: + source_to_display_str = "\n".join(source_to_display) + # it's a multiline node. don't try to highlight the whole thing + # just print the err and the offending text + location = f"{file_name_str}:{meta.line}-{end_line}" + return f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(color(msg))}\n{source_to_display_str}" + + node_start_line_in_ctx = meta.line - 1 - source_start_line + # end_column can be None for $END token + end_column = meta.end_column if meta.end_column is not None else meta.column + 1 + caret_str = "^" * (end_column - meta.column) + error_highlight = " " * (meta.column - 1 + line_number_space + 3) + color(caret_str) + source_to_display.insert(node_start_line_in_ctx + 1, error_highlight) + location = f"{file_name_str}:{meta.line}" + result = f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(color(msg))}\n" + result += "\n".join(source_to_display) + + return result + + @dataclass class CompileError(Exception): msg: str @@ -85,64 +151,7 @@ def __str__(self): return self.__repr__() def __repr__(self): - - stack_trace_optional = f"{self.stack_trace}\n" if debug else "" - file_name_str = file_name if file_name is not None else "" - - if self.node is None: - return f"{stack_trace_optional}{Colors.cyan(file_name_str)}: {Colors.bold(Colors.red(self.msg))}" - - meta = self.node if isinstance(self.node, Token) else self.node.meta - - source_start_line = meta.line - 1 - COMPILER_ERROR_CONTEXT_LINE_COUNT - source_start_line = max(0, source_start_line) - # end_line can be None for $END token - end_line = meta.end_line if meta.end_line is not None else meta.line - source_end_line = end_line - 1 + COMPILER_ERROR_CONTEXT_LINE_COUNT - source_end_line = min(len(input_lines), source_end_line) - - # this is the list of all the src lines we will display - source_to_display: list[str] = input_lines[ - source_start_line : source_end_line + 1 - ] - - # reserve this much space for the line numbers - # add two extra spaces for the caret to display multiline errors on lhs - line_number_space = 6 if source_end_line < 998 else 10 - - node_lines = end_line - meta.line - - # prefix all the lines with the prefix and line number - # right justified line number, then a |, then the line - # also if this is a multiline error, highlight the lines that errored with a > - source_to_display = [ - ( - ("> " if line_idx in range(meta.line - 1, end_line - 1) else "") - + str(source_start_line + line_idx + 1) - ).rjust(line_number_space) - + " | " - + line - for line_idx, line in enumerate(source_to_display) - ] - - if node_lines > 1: - source_to_display_str = "\n".join(source_to_display) - # it's a multiline node. don't try to highlight the whole thing - # just print the err and the offending text - location = f"{file_name_str}:{meta.line}-{end_line}" - return f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(Colors.red(self.msg))}\n{source_to_display_str}" - - node_start_line_in_ctx = meta.line - 1 - source_start_line - # end_column can be None for $END token - end_column = meta.end_column if meta.end_column is not None else meta.column + 1 - caret_str = "^" * (end_column - meta.column) - error_highlight = " " * (meta.column - 1 + line_number_space + 3) + Colors.red(caret_str) - source_to_display.insert(node_start_line_in_ctx + 1, error_highlight) - location = f"{file_name_str}:{meta.line}" - result = f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(Colors.red(self.msg))}\n" - result += "\n".join(source_to_display) - - return result + return format_diagnostic(self.msg, self.node, self.stack_trace, Colors.red) @dataclass diff --git a/src/fpy/main.py b/src/fpy/main.py index 6570446..41e6e7e 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -36,6 +36,7 @@ from fpy.codegen_llvm import backend_version_str from fpy.dictionary import load_dictionary from fpy.state import get_base_compile_state +from fpy.warnings import parse_warning_set def human_readable_size(size_bytes): @@ -108,6 +109,18 @@ def compile_main(args: list[str] = None): default=None, help="Local directory to resolve Fpy binary file paths. Needed for sequence argument type checking when calling sequences (default: input file directory)", ) + arg_parser.add_argument( + "--ignore", + type=str, + default="", + help="Comma-separated warning types to silence (e.g. 'import-side-effects,empty-range'), or 'all'", + ) + arg_parser.add_argument( + "--error", + type=str, + default="", + help="Comma-separated warning types to promote to hard errors, or 'all'", + ) if args is not None: parsed_args = arg_parser.parse_args(args) else: @@ -116,6 +129,21 @@ def compile_main(args: list[str] = None): if parsed_args.debug: fpy.error.debug = True + try: + ignored_warnings = parse_warning_set(parsed_args.ignore) + error_warnings = parse_warning_set(parsed_args.error) + except ValueError as e: + print(e, file=sys.stderr) + sys.exit(1) + overlap = ignored_warnings & error_warnings + if overlap: + names = ", ".join(sorted(w.value for w in overlap)) + print( + f"Warning type(s) cannot be both ignored and promoted to errors: {names}", + file=sys.stderr, + ) + sys.exit(1) + if not parsed_args.input.exists(): print(f"Input file {parsed_args.input} does not exist") sys.exit(1) @@ -130,6 +158,10 @@ def compile_main(args: list[str] = None): state = get_base_compile_state( str(parsed_args.dictionary.resolve()), str(ground_binary_dir.resolve()), + ignored_warnings=ignored_warnings, + error_warnings=error_warnings, + # imports resolve relative to the current working directory + import_dir=str(Path.cwd()), ) except fpy.error.DictionaryError as e: print(e, file=sys.stderr) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index c66cb07..94aa3f6 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -49,6 +49,7 @@ CompileState, ForLoopAnalysis, ) +from fpy.warnings import WarningType from fpy.symbols import ( BuiltinFuncSymbol, CallableSymbol, @@ -2934,7 +2935,7 @@ def visit_AstRange(self, node: AstRange, state: CompileState): return if lower_value.val >= upper_value.val: - state.warn("Range is empty", node) + state.warn(WarningType.EMPTY_RANGE, "Range is empty", node) class CheckSequenceArgs(Visitor): diff --git a/src/fpy/state.py b/src/fpy/state.py index f82a72d..192acf7 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -5,6 +5,7 @@ from fpy.dictionary import json_default_to_fpy_value, load_dictionary from fpy.error import CompileError, DictionaryError +from fpy.warnings import CompileWarning, WarningType from fpy.ir import Ir, IrLabel from fpy.macros import MACROS from fpy.symbols import CallableSymbol, CastSymbol, CommandSymbol, Symbol, SymbolTable, TypeCtorSymbol, VariableSymbol, create_symbol_table, merge_symbol_tables @@ -159,8 +160,20 @@ class CompileState: errors: list[CompileError] = field(default_factory=list) """a list of all compile exceptions generated by passes""" - warnings: list[CompileError] = field(default_factory=list) - """a list of all compiler warnings generated by passes""" + warnings: list[CompileWarning] = field(default_factory=list) + """a list of all compiler warnings generated by passes (after filtering out + ignored ones; escalated ones are appended to `errors` instead)""" + + ignored_warnings: set[WarningType] = field(default_factory=set) + """warning types to silently drop (`--ignore`)""" + + error_warnings: set[WarningType] = field(default_factory=set) + """warning types to promote to hard compile errors (`--error`)""" + + import_dir: str | None = None + """root directory against which `import` statements resolve source files. + Defaults to the current working directory in the CLI. Distinct from + `ground_binary_dir`, which roots runtime sequence-binary (.bin) paths.""" next_anon_var_id: int = 0 @@ -173,8 +186,14 @@ def err(self, msg, n): """adds a compile exception to internal state""" self.errors.append(CompileError(msg, n)) - def warn(self, msg, n): - self.warnings.append(CompileError("Warning: " + msg, n)) + def warn(self, type: WarningType, msg, n): + """Record a warning of the given type""" + if type in self.ignored_warnings: + return + if type in self.error_warnings: + self.errors.append(CompileError(f"{msg} [{type.value}]", n)) + return + self.warnings.append(CompileWarning(type, msg, n)) def _validate_and_replace_type( @@ -548,7 +567,11 @@ def _build_global_scopes(dictionary: str) -> tuple: def get_base_compile_state( - dictionary: str, ground_binary_dir: str | None = None + dictionary: str, + ground_binary_dir: str | None = None, + ignored_warnings: set[WarningType] | None = None, + error_warnings: set[WarningType] | None = None, + import_dir: str | None = None, ) -> CompileState: """return the initial state of the compiler, based on the given dict path""" type_scope, callable_scope, values_scope, type_defs = _build_global_scopes( @@ -582,6 +605,9 @@ def _const_int(key: str, default: int) -> int: max_directive_size=_const_int( "Svc.Fpy.MAX_DIRECTIVE_SIZE", DEFAULT_MAX_DIRECTIVE_SIZE ), + ignored_warnings=set(ignored_warnings) if ignored_warnings else set(), + error_warnings=set(error_warnings) if error_warnings else set(), + import_dir=import_dir, ) # Create the built-in 'flags' variable ($Flags struct). diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 6b7bfae..7347de6 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -15,7 +15,7 @@ analysis_to_fpybc_directives, analysis_to_wasm, ) -from fpy.state import get_base_compile_state +from fpy.state import CompileState, get_base_compile_state from fpy.bytecode.assembler import serialize_directives from fpy.dictionary import load_dictionary from fpy.types import FpyType, FpyValue @@ -42,12 +42,26 @@ class CompilationFailed(Exception): def compile_seq( - fprime_test_api, seq: str, ground_binary_dir: str = None -) -> tuple[list[Directive], list[tuple[str, FpyType]]]: - """Compile a sequence string to a list of directives and arg types.""" + seq: str, + ground_binary_dir: str = None, + ignored_warnings=None, + error_warnings=None, + import_dir: str = None, +) -> tuple[CompileState, list[Directive], list[tuple[str, FpyType]]]: + """Compile a sequence string and return (state, directives, arg_types). + + Exposes the CompileState so tests can inspect collected warnings. + Raises CompilationFailed on a compile/backend error (which is also how an + `error_warnings`-escalated warning surfaces).""" fpy.error.file_name = "" - state = get_base_compile_state(default_dictionary, ground_binary_dir) + state = get_base_compile_state( + default_dictionary, + ground_binary_dir, + ignored_warnings=ignored_warnings, + error_warnings=error_warnings, + import_dir=import_dir, + ) try: body = text_to_ast(seq) @@ -56,7 +70,7 @@ def compile_seq( except (fpy.error.CompileError, fpy.error.BackendError) as e: raise CompilationFailed(f"Compilation failed:\n{e}") - return directives, arg_types + return state, directives, arg_types def compile_seq_wasm(seq: str, ground_binary_dir: str = None) -> bytes: @@ -239,7 +253,7 @@ def assert_compile_success(fprime_test_api, seq: str): if USE_WASM: compile_seq_wasm(seq) return - compile_seq(fprime_test_api, seq) + compile_seq(seq) def assert_run_success( @@ -260,8 +274,8 @@ def assert_run_success( if code != DirectiveErrorCode.NO_ERROR.value: raise RuntimeError(f"wasm sequence returned error code {code}") return - directives, arg_name_types = compile_seq( - fprime_test_api, seq, ground_binary_dir=ground_binary_dir + _, directives, arg_name_types = compile_seq( + seq, ground_binary_dir=ground_binary_dir ) arg_types = [t for _, t in arg_name_types] args_bytes = None @@ -294,7 +308,7 @@ def assert_compile_failure( if USE_WASM: compile_seq_wasm(seq, ground_binary_dir=ground_binary_dir) else: - compile_seq(fprime_test_api, seq, ground_binary_dir=ground_binary_dir) + compile_seq(seq, ground_binary_dir=ground_binary_dir) except (SystemExit, CompilationFailed) as e: if match is not None: import re @@ -341,8 +355,8 @@ def assert_run_failure( ) return - directives, arg_name_types = compile_seq( - fprime_test_api, seq, ground_binary_dir=ground_binary_dir + _, directives, arg_name_types = compile_seq( + seq, ground_binary_dir=ground_binary_dir ) arg_types = [t for _, t in arg_name_types] args_bytes = None diff --git a/src/fpy/warnings.py b/src/fpy/warnings.py new file mode 100644 index 0000000..c3cfa7b --- /dev/null +++ b/src/fpy/warnings.py @@ -0,0 +1,60 @@ +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from fpy.error import Colors, format_diagnostic + + +class WarningType(str, Enum): + """The set of diagnostics the compiler may warn about.""" + + EMPTY_RANGE = "empty-range" + IMPORT_SIDE_EFFECTS = "import-side-effects" + + @classmethod + def from_value(cls, value: str) -> "WarningType": + """Look up a WarningType by its CLI string value. + + Raises ValueError (listing the valid values) if unknown.""" + for member in cls: + if member.value == value: + return member + valid = ", ".join(m.value for m in cls) + raise ValueError(f"Unknown warning type {value!r}. Valid types: {valid}") + + +# Sentinel accepted by --ignore / --error to mean "every warning type". +WARNING_ALL = "all" + + +def parse_warning_set(spec: str) -> set[WarningType]: + """Parse a comma-separated `--ignore`/`--error` spec into a set of types. + + Empty/whitespace-only entries are skipped. The special value "all" + expands to every WarningType. Raises ValueError on an unknown type.""" + result: set[WarningType] = set() + for raw in spec.split(","): + token = raw.strip() + if not token: + continue + if token == WARNING_ALL: + result.update(WarningType) + continue + result.add(WarningType.from_value(token)) + return result + + +@dataclass +class CompileWarning: + """A non-fatal diagnostic.""" + + type: WarningType + msg: str + node: Any = None + + def __str__(self) -> str: + label = f"warning [{self.type.value}]: {self.msg}" + return format_diagnostic(label, self.node, color=Colors.yellow) + + __repr__ = __str__ diff --git a/test/fpy/test_logging.py b/test/fpy/test_logging.py index 7fb59f2..0cd949c 100644 --- a/test/fpy/test_logging.py +++ b/test/fpy/test_logging.py @@ -26,7 +26,7 @@ def test_default_severity_is_activity_hi(self, fprime_test_api): seq = ''' log("test message") ''' - directives, _ = compile_seq(fprime_test_api, seq) + _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 # ACTIVITY_HI = 5 @@ -37,7 +37,7 @@ def test_explicit_fatal(self, fprime_test_api): seq = ''' log("critical", Fw.LogSeverity.FATAL) ''' - directives, _ = compile_seq(fprime_test_api, seq) + _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 # FATAL = 1 @@ -48,7 +48,7 @@ def test_explicit_warning_hi(self, fprime_test_api): seq = ''' log("watch out", Fw.LogSeverity.WARNING_HI) ''' - directives, _ = compile_seq(fprime_test_api, seq) + _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 # WARNING_HI = 2 @@ -58,7 +58,7 @@ def test_emits_pop_event_directive(self, fprime_test_api): seq = ''' log("test") ''' - directives, _ = compile_seq(fprime_test_api, seq) + _, directives, _ = compile_seq(seq) pop_dirs = [d for d in directives if isinstance(d, PopEventDirective)] assert len(pop_dirs) == 1 # message_size should be pushed onto the stack before POP_EVENT @@ -70,7 +70,7 @@ def test_serialization_roundtrip(self, fprime_test_api): seq = ''' log("roundtrip test") ''' - directives, _ = compile_seq(fprime_test_api, seq) + _, directives, _ = compile_seq(seq) pop_dirs = [d for d in directives if isinstance(d, PopEventDirective)] assert len(pop_dirs) == 1 diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index 61d7aef..aae8e35 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -33,7 +33,7 @@ def test_compile_main_ground_binary_dir(monkeypatch, tmp_path, capsys): captured_kwargs = {} - def fake_get_base_compile_state(dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): captured_kwargs["ground_binary_dir"] = ground_binary_dir return "STATE" @@ -70,7 +70,7 @@ def test_compile_main_ground_binary_dir_defaults_to_input_parent(monkeypatch, tm captured_kwargs = {} - def fake_get_base_compile_state(dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): captured_kwargs["ground_binary_dir"] = ground_binary_dir return "STATE" @@ -120,7 +120,7 @@ def test_compile_main_fpyasm_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_error, "debug", False, raising=False) monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -164,7 +164,7 @@ def test_compile_main_wat_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -197,7 +197,7 @@ def test_compile_main_binary_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( @@ -340,7 +340,7 @@ def fake_text_to_ast(text): monkeypatch.setattr(fpy_main, "text_to_ast", fake_text_to_ast) monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -374,7 +374,7 @@ def test_cmd_main_compile_error(monkeypatch, capsys): """Exit 1 when the compiler raises an error.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -398,7 +398,7 @@ def test_cmd_main_non_const_arg(monkeypatch, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( @@ -420,7 +420,7 @@ def test_cmd_main_send_failure(monkeypatch, capsys): """Exit 1 when the ZMQ send raises an exception.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -451,7 +451,7 @@ def test_cmd_main_ground_binary_dir(monkeypatch, tmp_path, capsys): captured_kwargs = {} - def fake_get_base_compile_state(dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): captured_kwargs["ground_binary_dir"] = ground_binary_dir return "STATE" @@ -479,7 +479,7 @@ def test_cmd_main_zmq_addr(monkeypatch, capsys): """--zmq-addr is passed through to send_command_zmq.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -526,7 +526,7 @@ def test_depend_main_ground_binary_dir_resolved(monkeypatch, tmp_path): captured = {} - def fake_get_base_compile_state(dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): captured["ground_binary_dir"] = ground_binary_dir return "STATE" @@ -547,7 +547,7 @@ def test_depend_main_default_ground_binary_dir(monkeypatch, tmp_path): captured = {} - def fake_get_base_compile_state(dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): captured["ground_binary_dir"] = ground_binary_dir return "STATE" @@ -566,7 +566,7 @@ def test_depend_main_compile_error_exits(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) def raise_compile_error(_body, _state): @@ -588,7 +588,7 @@ def test_depend_main_outputs_deps(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" ) monkeypatch.setattr( fpy_main, diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 79a09ae..53b635e 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -625,7 +625,7 @@ def test_arg_name_exactly_255_bytes(self, fprime_test_api): sequence({name_255}: U32) CdhCore.cmdDisp.CMD_NO_OP() """ - compile_seq(fprime_test_api, seq) + compile_seq(seq) def _dict_with_seq_args_buffer_size(buffer_size: int, tmpdir: Path) -> str: diff --git a/test/fpy/test_warnings.py b/test/fpy/test_warnings.py new file mode 100644 index 0000000..aeaee1c --- /dev/null +++ b/test/fpy/test_warnings.py @@ -0,0 +1,128 @@ +"""Tests for the typed compiler warning system. + +Warnings are non-fatal diagnostics with a stable `WarningType` string that the +user can silence (`--ignore`) or promote to a hard error (`--error`). These +tests drive the machinery through the `empty-range` warning, which is the one +warning currently emitted by semantic analysis. +""" +import pytest + +from fpy.test_helpers import CompilationFailed, compile_seq +from fpy.warnings import ( + WARNING_ALL, + CompileWarning, + WarningType, + parse_warning_set, +) + + +# A sequence whose for-loop range is statically empty (5 >= 3), which triggers +# the EMPTY_RANGE warning during semantic analysis. +EMPTY_RANGE_SEQ = """\ +for i in 5 .. 3: + pass +""" + + +class TestWarningTypeParsing: + """Parsing of the comma-separated --ignore / --error CLI specs.""" + + def test_single_type(self): + assert parse_warning_set("empty-range") == {WarningType.EMPTY_RANGE} + + def test_comma_separated(self): + assert parse_warning_set("empty-range,import-side-effects") == { + WarningType.EMPTY_RANGE, + WarningType.IMPORT_SIDE_EFFECTS, + } + + def test_whitespace_tolerated(self): + assert parse_warning_set(" empty-range , import-side-effects ") == { + WarningType.EMPTY_RANGE, + WarningType.IMPORT_SIDE_EFFECTS, + } + + def test_empty_spec_is_empty_set(self): + assert parse_warning_set("") == set() + assert parse_warning_set(" ") == set() + + def test_all_expands_to_every_type(self): + assert parse_warning_set(WARNING_ALL) == set(WarningType) + + def test_unknown_type_raises(self): + with pytest.raises(ValueError, match="Unknown warning type"): + parse_warning_set("not-a-real-warning") + + def test_from_value_roundtrip(self): + for member in WarningType: + assert WarningType.from_value(member.value) is member + + +class TestWarningEmission: + """A warning is collected on the state and does not fail compilation.""" + + def test_empty_range_warns(self): + state, _, _ = compile_seq(EMPTY_RANGE_SEQ) + assert any(w.type == WarningType.EMPTY_RANGE for w in state.warnings), ( + f"expected an empty-range warning, got {state.warnings}" + ) + + def test_warning_does_not_fail_compile(self): + # Should not raise -- warnings are non-fatal by default. + compile_seq(EMPTY_RANGE_SEQ) + + +class TestIgnoreWarnings: + """--ignore silently drops the warning.""" + + def test_ignore_suppresses_warning(self): + state, _, _ = compile_seq( + EMPTY_RANGE_SEQ, ignored_warnings={WarningType.EMPTY_RANGE} + ) + assert state.warnings == [] + + def test_ignore_all_suppresses_warning(self): + state, _, _ = compile_seq( + EMPTY_RANGE_SEQ, ignored_warnings=set(WarningType) + ) + assert state.warnings == [] + + def test_ignore_unrelated_type_keeps_warning(self): + state, _, _ = compile_seq( + EMPTY_RANGE_SEQ, ignored_warnings={WarningType.IMPORT_SIDE_EFFECTS} + ) + assert any(w.type == WarningType.EMPTY_RANGE for w in state.warnings) + + +class TestEscalateWarnings: + """--error promotes the warning to a hard compile error.""" + + def test_error_escalates_to_failure(self): + with pytest.raises(CompilationFailed): + compile_seq( + EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE} + ) + + def test_escalated_message_mentions_type(self): + with pytest.raises(CompilationFailed, match=r"empty-range"): + compile_seq( + EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE} + ) + + def test_unrelated_error_type_does_not_fail(self): + # Escalating a different warning type must not affect the empty-range warning. + state, _, _ = compile_seq( + EMPTY_RANGE_SEQ, error_warnings={WarningType.IMPORT_SIDE_EFFECTS} + ) + assert any(w.type == WarningType.EMPTY_RANGE for w in state.warnings) + + +class TestWarningRendering: + """The rendered warning is clearly labelled and carries its type.""" + + def test_str_contains_type_and_message(self): + w = CompileWarning(WarningType.EMPTY_RANGE, "Range is empty", None) + rendered = str(w) + assert "warning" in rendered + assert "empty-range" in rendered + assert "Range is empty" in rendered diff --git a/test/spacewasm_runner/src/main.rs b/test/spacewasm_runner/src/main.rs index a2617f0..9ab4ae7 100644 --- a/test/spacewasm_runner/src/main.rs +++ b/test/spacewasm_runner/src/main.rs @@ -185,7 +185,7 @@ fn run(wasm_path: &str, entry: &str) -> Result { .map_err(|e| format!("store: {e:?}"))?; let mut code_builder = CodeBuilder::<256>::default(); - let mut stream = ByteStream::new(&wasm); + let mut stream: ByteStream = ByteStream::new(&wasm); let module = Module::new::<256>( "seq", &mut stream, @@ -253,7 +253,7 @@ fn run(wasm_path: &str, entry: &str) -> Result { match result { InterpreterResult::Instruction(InterpreterBreak::Finished) => { - let raw = state.result.ok_or("entry returned no value")?; + let raw: spacewasm::RawValue = state.result.ok_or("entry returned no value")?; match raw.to_value(spacewasm::ValType::I32) { Value::I32(code) => Ok(code), other => Err(format!("entry returned non-i32: {other:?}")), From 04eed640ac1bac03bcc3c3b153924248372525a0 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Wed, 1 Jul 2026 01:36:35 -0400 Subject: [PATCH 02/10] Tests for import --- src/fpy/test_helpers.py | 66 +++- test/fpy/test_imports.py | 633 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 685 insertions(+), 14 deletions(-) create mode 100644 test/fpy/test_imports.py diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 7347de6..039116a 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -73,11 +73,23 @@ def compile_seq( return state, directives, arg_types -def compile_seq_wasm(seq: str, ground_binary_dir: str = None) -> bytes: +def compile_seq_wasm( + seq: str, + ground_binary_dir: str = None, + import_dir: str = None, + ignored_warnings=None, + error_warnings=None, +) -> bytes: """Compile a sequence string to a runnable wasm binary (the LLVM backend).""" fpy.error.file_name = "" - state = get_base_compile_state(default_dictionary, ground_binary_dir) + state = get_base_compile_state( + default_dictionary, + ground_binary_dir, + ignored_warnings=ignored_warnings, + error_warnings=error_warnings, + import_dir=import_dir, + ) try: body = text_to_ast(seq) @@ -89,7 +101,9 @@ def compile_seq_wasm(seq: str, ground_binary_dir: str = None) -> bytes: return wasm -def run_seq_wasm(seq: str, ground_binary_dir: str = None) -> int: +def run_seq_wasm( + seq: str, ground_binary_dir: str = None, import_dir: str = None +) -> int: """Compile *seq* to wasm and run it, returning fpy_main's error code. Runs the compiled module through the NASA spacewasm interpreter (the @@ -100,7 +114,7 @@ def run_seq_wasm(seq: str, ground_binary_dir: str = None) -> int: "SPACEWASM_RUNNER not set; run pytest with --wasm" ) - wasm = compile_seq_wasm(seq, ground_binary_dir) + wasm = compile_seq_wasm(seq, ground_binary_dir, import_dir=import_dir) wasm_file = tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) wasm_file.write(wasm) wasm_file.close() @@ -249,11 +263,11 @@ def run_seq( raise RuntimeError(f"Sequence leaked {len(model.stack) - expected_stack} bytes") -def assert_compile_success(fprime_test_api, seq: str): +def assert_compile_success(fprime_test_api, seq: str, import_dir: str = None): if USE_WASM: - compile_seq_wasm(seq) + compile_seq_wasm(seq, import_dir=import_dir) return - compile_seq(seq) + compile_seq(seq, import_dir=import_dir) def assert_run_success( @@ -268,14 +282,17 @@ def assert_run_success( args: list[FpyValue] = None, ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, + import_dir: str = None, ): if USE_WASM: - code = run_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + code = run_seq_wasm( + seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + ) if code != DirectiveErrorCode.NO_ERROR.value: raise RuntimeError(f"wasm sequence returned error code {code}") return _, directives, arg_name_types = compile_seq( - seq, ground_binary_dir=ground_binary_dir + seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir ) arg_types = [t for _, t in arg_name_types] args_bytes = None @@ -302,13 +319,31 @@ def assert_run_success( def assert_compile_failure( - fprime_test_api, seq: str, match: str = None, ground_binary_dir: str = None + fprime_test_api, + seq: str, + match: str = None, + ground_binary_dir: str = None, + import_dir: str = None, + ignored_warnings=None, + error_warnings=None, ): try: if USE_WASM: - compile_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + compile_seq_wasm( + seq, + ground_binary_dir=ground_binary_dir, + import_dir=import_dir, + ignored_warnings=ignored_warnings, + error_warnings=error_warnings, + ) else: - compile_seq(seq, ground_binary_dir=ground_binary_dir) + compile_seq( + seq, + ground_binary_dir=ground_binary_dir, + import_dir=import_dir, + ignored_warnings=ignored_warnings, + error_warnings=error_warnings, + ) except (SystemExit, CompilationFailed) as e: if match is not None: import re @@ -332,6 +367,7 @@ def assert_run_failure( args: list[FpyValue] = None, ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, + import_dir: str = None, ): assert not ( error_code is not None and validation_error @@ -343,7 +379,9 @@ def assert_run_failure( if USE_WASM: # The wasm backend has no separate validation step or VM-internal # faults: a failed sequence is one whose entry point returns nonzero. - code = run_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + code = run_seq_wasm( + seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + ) if code == DirectiveErrorCode.NO_ERROR.value: raise RuntimeError("wasm sequence succeeded") if error_code is not None: @@ -356,7 +394,7 @@ def assert_run_failure( return _, directives, arg_name_types = compile_seq( - seq, ground_binary_dir=ground_binary_dir + seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir ) arg_types = [t for _, t in arg_name_types] args_bytes = None diff --git a/test/fpy/test_imports.py b/test/fpy/test_imports.py new file mode 100644 index 0000000..5d32569 --- /dev/null +++ b/test/fpy/test_imports.py @@ -0,0 +1,633 @@ +"""Tests for the `import` statement (spec / TDD). + +`import foo` inlines the definitions of a sibling `foo.fpy` source file into +the importing sequence and sections its symbols off under the name `foo`, so a +function `bar` defined in `foo.fpy` is called as `foo.bar()`. + +Design decisions encoded here (see conversation): + * Import path resolves relative to `state.import_dir` (defaults to cwd in the + CLI). This is DISTINCT from `ground_binary_dir`, which roots runtime + sequence-binary (.bin) paths -- imports are a compile-time-only source + inlining and never survive into the emitted bytecode. + * MVP syntax is a bare name: `import foo` -> `foo.fpy`. Dotted module paths + are a later extension. + * `import` is only valid as a top-level statement (not nested in a block), + but an imported module MAY itself import other modules (transitive imports + are supported, with cycle detection). + * Inlining an imported file that does more than define functions emits the + `import-side-effects` warning (its top-level code runs as part of the + importing sequence). + * Importing a file that declares sequence arguments (`sequence(x: U32)`) is a + hard error. + +Every sequence that is expected to compile is also *run* (via +`assert_run_success`), so the asserts embedded in the sequences actually +execute. Every sequence that is expected to fail compilation uses +`assert_compile_failure`. These tests are `xfail` until the import passes are +implemented; they define the target behavior. Remove the `pytestmark` once +`import` is implemented. +""" +from pathlib import Path + +import pytest + +from fpy.test_helpers import ( + assert_compile_failure, + assert_run_success, + compile_seq, +) +from fpy.warnings import WarningType + +# The whole module targets not-yet-implemented behavior. +pytestmark = pytest.mark.xfail( + reason="import statement not yet implemented", strict=False +) + + +def _write_module(import_dir: Path, name: str, src: str) -> None: + """Write an importable module `.fpy` into *import_dir*.""" + (import_dir / f"{name}.fpy").write_text(src) + + +class TestImportInlining: + """An imported function is inlined and callable under the module name.""" + + def test_call_imported_function(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "lib", + """\ +def add_one(x: U32) -> U32: + return x + 1 +""", + ) + main = """\ +import lib + +result: U32 = lib.add_one(41) +assert result == 42 +""" + # Funcs-only module: compiles cleanly with no side-effect warning... + state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + assert state.warnings == [] + # ...and the embedded assert holds at run time. + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_imported_function_runs(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "lib", + """\ +def double(x: U32) -> U32: + return x * 2 +""", + ) + main = """\ +import lib + +v: U32 = lib.double(21) +assert v == 42 +""" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_local_and_imported_names_coexist(self, fprime_test_api, tmp_path): + """A local `helper` and an imported `lib.helper` must not collide -- + the imported symbols are sectioned off under `lib`.""" + _write_module( + tmp_path, + "lib", + """\ +def helper() -> U32: + return 1 +""", + ) + main = """\ +import lib + +def helper() -> U32: + return 2 + +a: U32 = helper() +b: U32 = lib.helper() +assert a == 2 +assert b == 1 +""" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportSideEffects: + """Importing a file with top-level, non-def code warns.""" + + SIDE_EFFECT_MODULE = """\ +CdhCore.cmdDisp.CMD_NO_OP() + +def noop_wrapper(): + CdhCore.cmdDisp.CMD_NO_OP() +""" + + def test_side_effecting_import_warns(self, fprime_test_api, tmp_path): + _write_module(tmp_path, "sfx", self.SIDE_EFFECT_MODULE) + main = """\ +import sfx + +sfx.noop_wrapper() +""" + state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + assert any( + w.type == WarningType.IMPORT_SIDE_EFFECTS for w in state.warnings + ), f"expected an import-side-effects warning, got {state.warnings}" + # The warning is non-fatal: the sequence still compiles and runs. + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_side_effect_warning_can_be_ignored(self, fprime_test_api, tmp_path): + _write_module(tmp_path, "sfx", self.SIDE_EFFECT_MODULE) + main = """\ +import sfx + +sfx.noop_wrapper() +""" + state, _, _ = compile_seq( + main, + import_dir=str(tmp_path), + ignored_warnings={WarningType.IMPORT_SIDE_EFFECTS}, + ) + assert state.warnings == [] + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_side_effect_warning_can_be_escalated(self, fprime_test_api, tmp_path): + _write_module(tmp_path, "sfx", self.SIDE_EFFECT_MODULE) + main = """\ +import sfx + +sfx.noop_wrapper() +""" + assert_compile_failure( + fprime_test_api, + main, + match="import-side-effects", + import_dir=str(tmp_path), + error_warnings={WarningType.IMPORT_SIDE_EFFECTS}, + ) + + def test_functions_only_module_does_not_warn(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "clean", + """\ +def a() -> U32: + return 1 + +def b() -> U32: + return 2 +""", + ) + main = """\ +import clean + +x: U32 = clean.a() + clean.b() +assert x == 3 +""" + state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + assert state.warnings == [] + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportErrors: + """Error cases for import.""" + + def test_cannot_import_sequence_with_arguments(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "withargs", + """\ +sequence(x: U32) + +def f() -> U32: + return x +""", + ) + main = """\ +import withargs +""" + assert_compile_failure( + fprime_test_api, main, match="argument", import_dir=str(tmp_path) + ) + + def test_missing_module_is_an_error(self, fprime_test_api, tmp_path): + main = """\ +import does_not_exist +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_no_arg_sequence_is_importable(self, fprime_test_api, tmp_path): + """A bare `sequence()` with no arguments is importable -- only + sequences *with arguments* are rejected (per the feature's wording).""" + _write_module( + tmp_path, + "noargseq", + """\ +sequence() + +def f() -> U32: + return 1 +""", + ) + main = """\ +import noargseq + +x: U32 = noargseq.f() +assert x == 1 +""" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportFileErrors: + """Failure modes rooted in the imported file itself.""" + + def test_parse_error_in_imported_file_fails(self, fprime_test_api, tmp_path): + """A syntax error inside the imported file is a hard compile error. + Ideally the diagnostic points into the imported file, not the importer.""" + _write_module( + tmp_path, + "broken", + """\ +def f( -> + return 1 +""", + ) + main = """\ +import broken +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_import_path_is_a_directory_fails(self, fprime_test_api, tmp_path): + """If the resolved `.fpy` is a directory, importing fails cleanly + rather than crashing with an IO error.""" + (tmp_path / "adir.fpy").mkdir() + main = """\ +import adir +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_empty_module_compiles_without_warning(self, fprime_test_api, tmp_path): + """An empty module has no definitions and no side effects.""" + _write_module(tmp_path, "empty", "") + main = """\ +import empty + +CdhCore.cmdDisp.CMD_NO_OP() +""" + state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + assert state.warnings == [] + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportNamespaceIsolation: + """Imported symbols are sectioned under the module name and isolated.""" + + def test_imported_symbol_requires_module_prefix(self, fprime_test_api, tmp_path): + """`add_one` is only reachable as `lib.add_one`, never bare.""" + _write_module( + tmp_path, + "lib", + """\ +def add_one(x: U32) -> U32: + return x + 1 +""", + ) + main = """\ +import lib + +y: U32 = add_one(1) +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_module_name_not_usable_as_value(self, fprime_test_api, tmp_path): + """The module name is a namespace, not an expression.""" + _write_module( + tmp_path, + "lib", + """\ +def add_one(x: U32) -> U32: + return x + 1 +""", + ) + main = """\ +import lib + +y: U32 = lib +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_same_function_name_in_two_modules_no_collision( + self, fprime_test_api, tmp_path + ): + _write_module( + tmp_path, + "lib_a", + """\ +def helper() -> U32: + return 1 +""", + ) + _write_module( + tmp_path, + "lib_b", + """\ +def helper() -> U32: + return 2 +""", + ) + main = """\ +import lib_a +import lib_b + +a: U32 = lib_a.helper() +b: U32 = lib_b.helper() +assert a == 1 +assert b == 2 +""" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_imported_function_cannot_see_importer_globals( + self, fprime_test_api, tmp_path + ): + """An imported function is analyzed in its own module scope: a name + defined only in the importing sequence must NOT resolve inside it.""" + _write_module( + tmp_path, + "iso", + """\ +def uses_outside() -> U32: + return main_global +""", + ) + main = """\ +import iso + +main_global: U32 = 5 +x: U32 = iso.uses_outside() +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportNameCollisions: + """The module name must not clash with an existing top-level name.""" + + def test_import_collides_with_local_function(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "dup", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +import dup + +def dup() -> U32: + return 2 +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_import_collides_with_local_variable(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "dup", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +import dup + +dup: U32 = 3 +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportDuplicates: + """Importing the same module twice inlines it once.""" + + def test_duplicate_import_is_idempotent(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "lib", + """\ +def add_one(x: U32) -> U32: + return x + 1 +""", + ) + main = """\ +import lib +import lib + +y: U32 = lib.add_one(41) +assert y == 42 +""" + # Must not raise a duplicate-definition error from inlining twice. + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportOnlyAtTopLevel: + """`import` is only valid as a top-level statement (never nested in a + block). Note: an imported *module* may still contain its own top-level + imports -- see TestImportTransitive.""" + + def test_import_inside_if_block_fails(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "lib", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +if 1 == 1: + import lib +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_import_inside_function_fails(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "lib", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +def wrapper() -> U32: + import lib + return 1 +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportTransitive: + """An imported module may itself import other modules.""" + + def test_transitive_import_works(self, fprime_test_api, tmp_path): + """main -> a -> b: `a` uses `b` internally, and main runs `a.f()`.""" + _write_module( + tmp_path, + "b", + """\ +def g() -> U32: + return 7 +""", + ) + _write_module( + tmp_path, + "a", + """\ +import b + +def f() -> U32: + return b.g() +""", + ) + main = """\ +import a + +x: U32 = a.f() +assert x == 7 +""" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + + def test_transitive_dependency_is_private(self, fprime_test_api, tmp_path): + + _write_module( + tmp_path, + "b", + """\ +def g() -> U32: + return 7 +""", + ) + _write_module( + tmp_path, + "a", + """\ +import b + +def f() -> U32: + return b.g() +""", + ) + main = """\ +import a + +x: U32 = b.g() +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportCycles: + """Import cycles are detected and rejected.""" + + def test_self_import_is_cycle_error(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "selfmod", + """\ +import selfmod + +def f() -> U32: + return 1 +""", + ) + main = """\ +import selfmod +""" + assert_compile_failure( + fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + ) + + def test_mutual_import_is_cycle_error(self, fprime_test_api, tmp_path): + _write_module( + tmp_path, + "mod_a", + """\ +import mod_b + +def a() -> U32: + return mod_b.b() +""", + ) + _write_module( + tmp_path, + "mod_b", + """\ +import mod_a + +def b() -> U32: + return 1 +""", + ) + main = """\ +import mod_a +""" + assert_compile_failure( + fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + ) + + def test_three_way_cycle_error(self, fprime_test_api, tmp_path): + _write_module(tmp_path, "c1", "import c2\n\ndef f() -> U32:\n return 1\n") + _write_module(tmp_path, "c2", "import c3\n\ndef f() -> U32:\n return 1\n") + _write_module(tmp_path, "c3", "import c1\n\ndef f() -> U32:\n return 1\n") + main = """\ +import c1 +""" + assert_compile_failure( + fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + ) + + +class TestImportSyntax: + """Syntax-level constraints of the MVP.""" + + def test_dotted_import_rejected(self, fprime_test_api, tmp_path): + """Dotted module paths are not supported yet.""" + main = """\ +import pkg.mod +""" + assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + + +class TestImportVariables: + """A module's top-level variable is both a side effect and a namespaced + symbol.""" + + def test_top_level_variable_is_side_effect_and_namespaced( + self, fprime_test_api, tmp_path + ): + _write_module( + tmp_path, + "withvar", + """\ +counter: U32 = 5 + +def get() -> U32: + return counter +""", + ) + main = """\ +import withvar + +x: U32 = withvar.counter +assert x == 5 +assert withvar.counter == 5 +assert withvar.get() == 5 +""" + # The top-level assignment runs at sequence start -> side effect warning, + # but `withvar.counter` still resolves as a namespaced symbol. + state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + assert any( + w.type == WarningType.IMPORT_SIDE_EFFECTS for w in state.warnings + ), f"expected an import-side-effects warning, got {state.warnings}" + assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) From 75c820f6c28cb53b608ca23843dbb06b02dfa927 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Fri, 3 Jul 2026 15:09:51 -0400 Subject: [PATCH 03/10] Import --include argument --- src/fpy/main.py | 25 ++- src/fpy/state.py | 12 +- src/fpy/test_helpers.py | 36 ++--- test/fpy/test_imports.py | 337 +++++++++++++++++++++++++++++++++------ test/fpy/test_main.py | 76 +++++++++ 5 files changed, 412 insertions(+), 74 deletions(-) diff --git a/src/fpy/main.py b/src/fpy/main.py index 41e6e7e..159fbf3 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -109,6 +109,20 @@ def compile_main(args: list[str] = None): default=None, help="Local directory to resolve Fpy binary file paths. Needed for sequence argument type checking when calling sequences (default: input file directory)", ) + arg_parser.add_argument( + "-i", + "--include", + type=Path, + action="append", + default=[], + metavar="DIR", + dest="include", + help=( + "Directory to search when resolving `import` statements (repeatable). " + "The importing file's own directory is always searched first; each " + "--include directory is searched afterwards, in the order given" + ), + ) arg_parser.add_argument( "--ignore", type=str, @@ -152,7 +166,13 @@ def compile_main(args: list[str] = None): ground_binary_dir = parsed_args.ground_binary_dir if ground_binary_dir is None: ground_binary_dir = parsed_args.input.parent - + + # imports resolve against the importing file's own directory first, then + # each -i/--include directory in the order given + import_search_dirs = [str(parsed_args.input.parent.resolve())] + [ + str(d.resolve()) for d in parsed_args.include + ] + # reading dictionary try: state = get_base_compile_state( @@ -160,8 +180,7 @@ def compile_main(args: list[str] = None): str(ground_binary_dir.resolve()), ignored_warnings=ignored_warnings, error_warnings=error_warnings, - # imports resolve relative to the current working directory - import_dir=str(Path.cwd()), + import_search_dirs=import_search_dirs, ) except fpy.error.DictionaryError as e: print(e, file=sys.stderr) diff --git a/src/fpy/state.py b/src/fpy/state.py index 192acf7..583e2a3 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -170,10 +170,10 @@ class CompileState: error_warnings: set[WarningType] = field(default_factory=set) """warning types to promote to hard compile errors (`--error`)""" - import_dir: str | None = None - """root directory against which `import` statements resolve source files. - Defaults to the current working directory in the CLI. Distinct from - `ground_binary_dir`, which roots runtime sequence-binary (.bin) paths.""" + import_search_dirs: list[str] = field(default_factory=list) + """ordered list of directories searched to resolve `import` statements to + source files (first match wins). Populated from `-i/--include` in the CLI (in addition to the + importing file's own directory).""" next_anon_var_id: int = 0 @@ -571,7 +571,7 @@ def get_base_compile_state( ground_binary_dir: str | None = None, ignored_warnings: set[WarningType] | None = None, error_warnings: set[WarningType] | None = None, - import_dir: str | None = None, + import_search_dirs: list[str] | None = None, ) -> CompileState: """return the initial state of the compiler, based on the given dict path""" type_scope, callable_scope, values_scope, type_defs = _build_global_scopes( @@ -607,7 +607,7 @@ def _const_int(key: str, default: int) -> int: ), ignored_warnings=set(ignored_warnings) if ignored_warnings else set(), error_warnings=set(error_warnings) if error_warnings else set(), - import_dir=import_dir, + import_search_dirs=list(import_search_dirs) if import_search_dirs else [], ) # Create the built-in 'flags' variable ($Flags struct). diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 039116a..76ac122 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -46,7 +46,7 @@ def compile_seq( ground_binary_dir: str = None, ignored_warnings=None, error_warnings=None, - import_dir: str = None, + import_search_dirs: list[str] | None = None, ) -> tuple[CompileState, list[Directive], list[tuple[str, FpyType]]]: """Compile a sequence string and return (state, directives, arg_types). @@ -60,7 +60,7 @@ def compile_seq( ground_binary_dir, ignored_warnings=ignored_warnings, error_warnings=error_warnings, - import_dir=import_dir, + import_search_dirs=import_search_dirs, ) try: @@ -76,7 +76,7 @@ def compile_seq( def compile_seq_wasm( seq: str, ground_binary_dir: str = None, - import_dir: str = None, + import_search_dirs: list[str] | None = None, ignored_warnings=None, error_warnings=None, ) -> bytes: @@ -88,7 +88,7 @@ def compile_seq_wasm( ground_binary_dir, ignored_warnings=ignored_warnings, error_warnings=error_warnings, - import_dir=import_dir, + import_search_dirs=import_search_dirs, ) try: @@ -102,7 +102,7 @@ def compile_seq_wasm( def run_seq_wasm( - seq: str, ground_binary_dir: str = None, import_dir: str = None + seq: str, ground_binary_dir: str = None, import_search_dirs: list[str] | None = None ) -> int: """Compile *seq* to wasm and run it, returning fpy_main's error code. @@ -114,7 +114,7 @@ def run_seq_wasm( "SPACEWASM_RUNNER not set; run pytest with --wasm" ) - wasm = compile_seq_wasm(seq, ground_binary_dir, import_dir=import_dir) + wasm = compile_seq_wasm(seq, ground_binary_dir, import_search_dirs=import_search_dirs) wasm_file = tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) wasm_file.write(wasm) wasm_file.close() @@ -263,11 +263,11 @@ def run_seq( raise RuntimeError(f"Sequence leaked {len(model.stack) - expected_stack} bytes") -def assert_compile_success(fprime_test_api, seq: str, import_dir: str = None): +def assert_compile_success(fprime_test_api, seq: str, import_search_dirs: list[str] | None = None): if USE_WASM: - compile_seq_wasm(seq, import_dir=import_dir) + compile_seq_wasm(seq, import_search_dirs=import_search_dirs) return - compile_seq(seq, import_dir=import_dir) + compile_seq(seq, import_search_dirs=import_search_dirs) def assert_run_success( @@ -282,17 +282,17 @@ def assert_run_success( args: list[FpyValue] = None, ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, - import_dir: str = None, + import_search_dirs: list[str] | None = None, ): if USE_WASM: code = run_seq_wasm( - seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs ) if code != DirectiveErrorCode.NO_ERROR.value: raise RuntimeError(f"wasm sequence returned error code {code}") return _, directives, arg_name_types = compile_seq( - seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs ) arg_types = [t for _, t in arg_name_types] args_bytes = None @@ -323,7 +323,7 @@ def assert_compile_failure( seq: str, match: str = None, ground_binary_dir: str = None, - import_dir: str = None, + import_search_dirs: list[str] | None = None, ignored_warnings=None, error_warnings=None, ): @@ -332,7 +332,7 @@ def assert_compile_failure( compile_seq_wasm( seq, ground_binary_dir=ground_binary_dir, - import_dir=import_dir, + import_search_dirs=import_search_dirs, ignored_warnings=ignored_warnings, error_warnings=error_warnings, ) @@ -340,7 +340,7 @@ def assert_compile_failure( compile_seq( seq, ground_binary_dir=ground_binary_dir, - import_dir=import_dir, + import_search_dirs=import_search_dirs, ignored_warnings=ignored_warnings, error_warnings=error_warnings, ) @@ -367,7 +367,7 @@ def assert_run_failure( args: list[FpyValue] = None, ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, - import_dir: str = None, + import_search_dirs: list[str] | None = None, ): assert not ( error_code is not None and validation_error @@ -380,7 +380,7 @@ def assert_run_failure( # The wasm backend has no separate validation step or VM-internal # faults: a failed sequence is one whose entry point returns nonzero. code = run_seq_wasm( - seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs ) if code == DirectiveErrorCode.NO_ERROR.value: raise RuntimeError("wasm sequence succeeded") @@ -394,7 +394,7 @@ def assert_run_failure( return _, directives, arg_name_types = compile_seq( - seq, ground_binary_dir=ground_binary_dir, import_dir=import_dir + seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs ) arg_types = [t for _, t in arg_name_types] args_bytes = None diff --git a/test/fpy/test_imports.py b/test/fpy/test_imports.py index 5d32569..3c63ac6 100644 --- a/test/fpy/test_imports.py +++ b/test/fpy/test_imports.py @@ -4,13 +4,28 @@ the importing sequence and sections its symbols off under the name `foo`, so a function `bar` defined in `foo.fpy` is called as `foo.bar()`. -Design decisions encoded here (see conversation): - * Import path resolves relative to `state.import_dir` (defaults to cwd in the - CLI). This is DISTINCT from `ground_binary_dir`, which roots runtime +Design decisions encoded here: + * Import paths resolve against `state.import_search_dirs`, an ordered list of + directories searched first-match-wins. In the CLI this is the importing + file's own directory followed by each `-i/--include` directory. This is + DISTINCT from `ground_binary_dir`, which roots runtime sequence-binary (.bin) paths -- imports are a compile-time-only source inlining and never survive into the emitted bytecode. - * MVP syntax is a bare name: `import foo` -> `foo.fpy`. Dotted module paths - are a later extension. + * A bare name resolves to a sibling file: `import foo` -> `foo.fpy`. Dotted + module paths resolve through package directories, Pythonically: `import + a.b.c` -> `a/b/c.fpy`, searched against each `import_search_dirs` entry + first-match-wins. Package directories are plain namespace packages -- no + `__init__.fpy` marker is required. The name bound is the top segment, and + members are reached by the full path: `import a.b.c` makes `a.b.c.bar()` + callable (and `import foo` makes `foo.bar()` callable). + * File/package precedence follows Python's namespace-package rules. Because + no `__init__.fpy` marker is required, directories are namespace portions, + which rank BELOW modules: at an import's leaf segment a module file + `foo.fpy` outranks a same-named `foo/` directory. Non-leaf segments must be + package directories -- `import a.b` descends into `a/` to reach `a/b.fpy` + regardless of any sibling `a.fpy` module. Importing a leaf that resolves + only to a package directory (no module file to inline) is an error: a + namespace package has nothing to inline. * `import` is only valid as a top-level statement (not nested in a block), but an imported module MAY itself import other modules (transitive imports are supported, with cycle detection). @@ -44,9 +59,16 @@ ) -def _write_module(import_dir: Path, name: str, src: str) -> None: - """Write an importable module `.fpy` into *import_dir*.""" - (import_dir / f"{name}.fpy").write_text(src) +def _write_module(search_dir: Path, dotted_name: str, src: str) -> None: + """Write an importable module for `import ` into *search_dir*. + + A bare name `foo` becomes `/foo.fpy`; a dotted name `a.b.c` + becomes `/a/b/c.fpy`, creating the intervening package + directories.""" + rel = Path(*dotted_name.split(".")).with_suffix(".fpy") + path = search_dir / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(src) class TestImportInlining: @@ -68,10 +90,10 @@ def add_one(x: U32) -> U32: assert result == 42 """ # Funcs-only module: compiles cleanly with no side-effect warning... - state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) assert state.warnings == [] # ...and the embedded assert holds at run time. - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_imported_function_runs(self, fprime_test_api, tmp_path): _write_module( @@ -88,7 +110,7 @@ def double(x: U32) -> U32: v: U32 = lib.double(21) assert v == 42 """ - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_local_and_imported_names_coexist(self, fprime_test_api, tmp_path): """A local `helper` and an imported `lib.helper` must not collide -- @@ -112,7 +134,7 @@ def helper() -> U32: assert a == 2 assert b == 1 """ - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportSideEffects: @@ -132,12 +154,12 @@ def test_side_effecting_import_warns(self, fprime_test_api, tmp_path): sfx.noop_wrapper() """ - state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) assert any( w.type == WarningType.IMPORT_SIDE_EFFECTS for w in state.warnings ), f"expected an import-side-effects warning, got {state.warnings}" # The warning is non-fatal: the sequence still compiles and runs. - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_side_effect_warning_can_be_ignored(self, fprime_test_api, tmp_path): _write_module(tmp_path, "sfx", self.SIDE_EFFECT_MODULE) @@ -148,11 +170,11 @@ def test_side_effect_warning_can_be_ignored(self, fprime_test_api, tmp_path): """ state, _, _ = compile_seq( main, - import_dir=str(tmp_path), + import_search_dirs=[str(tmp_path)], ignored_warnings={WarningType.IMPORT_SIDE_EFFECTS}, ) assert state.warnings == [] - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_side_effect_warning_can_be_escalated(self, fprime_test_api, tmp_path): _write_module(tmp_path, "sfx", self.SIDE_EFFECT_MODULE) @@ -165,7 +187,7 @@ def test_side_effect_warning_can_be_escalated(self, fprime_test_api, tmp_path): fprime_test_api, main, match="import-side-effects", - import_dir=str(tmp_path), + import_search_dirs=[str(tmp_path)], error_warnings={WarningType.IMPORT_SIDE_EFFECTS}, ) @@ -187,9 +209,9 @@ def b() -> U32: x: U32 = clean.a() + clean.b() assert x == 3 """ - state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) assert state.warnings == [] - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportErrors: @@ -210,14 +232,14 @@ def f() -> U32: import withargs """ assert_compile_failure( - fprime_test_api, main, match="argument", import_dir=str(tmp_path) + fprime_test_api, main, match="argument", import_search_dirs=[str(tmp_path)] ) def test_missing_module_is_an_error(self, fprime_test_api, tmp_path): main = """\ import does_not_exist """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_no_arg_sequence_is_importable(self, fprime_test_api, tmp_path): """A bare `sequence()` with no arguments is importable -- only @@ -238,7 +260,7 @@ def f() -> U32: x: U32 = noargseq.f() assert x == 1 """ - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportFileErrors: @@ -258,7 +280,7 @@ def f( -> main = """\ import broken """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_import_path_is_a_directory_fails(self, fprime_test_api, tmp_path): """If the resolved `.fpy` is a directory, importing fails cleanly @@ -267,7 +289,7 @@ def test_import_path_is_a_directory_fails(self, fprime_test_api, tmp_path): main = """\ import adir """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_empty_module_compiles_without_warning(self, fprime_test_api, tmp_path): """An empty module has no definitions and no side effects.""" @@ -277,9 +299,9 @@ def test_empty_module_compiles_without_warning(self, fprime_test_api, tmp_path): CdhCore.cmdDisp.CMD_NO_OP() """ - state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) assert state.warnings == [] - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportNamespaceIsolation: @@ -300,7 +322,7 @@ def add_one(x: U32) -> U32: y: U32 = add_one(1) """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_module_name_not_usable_as_value(self, fprime_test_api, tmp_path): """The module name is a namespace, not an expression.""" @@ -317,7 +339,7 @@ def add_one(x: U32) -> U32: y: U32 = lib """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_same_function_name_in_two_modules_no_collision( self, fprime_test_api, tmp_path @@ -347,7 +369,7 @@ def helper() -> U32: assert a == 1 assert b == 2 """ - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_imported_function_cannot_see_importer_globals( self, fprime_test_api, tmp_path @@ -368,7 +390,7 @@ def uses_outside() -> U32: main_global: U32 = 5 x: U32 = iso.uses_outside() """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportNameCollisions: @@ -389,7 +411,7 @@ def f() -> U32: def dup() -> U32: return 2 """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_import_collides_with_local_variable(self, fprime_test_api, tmp_path): _write_module( @@ -405,7 +427,7 @@ def f() -> U32: dup: U32 = 3 """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportDuplicates: @@ -428,7 +450,7 @@ def add_one(x: U32) -> U32: assert y == 42 """ # Must not raise a duplicate-definition error from inlining twice. - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportOnlyAtTopLevel: @@ -449,7 +471,7 @@ def f() -> U32: if 1 == 1: import lib """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_import_inside_function_fails(self, fprime_test_api, tmp_path): _write_module( @@ -465,7 +487,7 @@ def wrapper() -> U32: import lib return 1 """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportTransitive: @@ -497,7 +519,7 @@ def f() -> U32: x: U32 = a.f() assert x == 7 """ - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) def test_transitive_dependency_is_private(self, fprime_test_api, tmp_path): @@ -524,7 +546,7 @@ def f() -> U32: x: U32 = b.g() """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportCycles: @@ -545,7 +567,7 @@ def f() -> U32: import selfmod """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] ) def test_mutual_import_is_cycle_error(self, fprime_test_api, tmp_path): @@ -573,7 +595,7 @@ def b() -> U32: import mod_a """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] ) def test_three_way_cycle_error(self, fprime_test_api, tmp_path): @@ -584,19 +606,240 @@ def test_three_way_cycle_error(self, fprime_test_api, tmp_path): import c1 """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_dir=str(tmp_path) + fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] ) -class TestImportSyntax: - """Syntax-level constraints of the MVP.""" +class TestImportDottedPaths: + """Dotted module paths resolve through package directories, Pythonically.""" - def test_dotted_import_rejected(self, fprime_test_api, tmp_path): - """Dotted module paths are not supported yet.""" + def test_single_dotted_import(self, fprime_test_api, tmp_path): + """`import pkg.mod` resolves `pkg/mod.fpy` and binds `pkg.mod`.""" + _write_module( + tmp_path, + "pkg.mod", + """\ +def add_one(x: U32) -> U32: + return x + 1 +""", + ) main = """\ import pkg.mod + +result: U32 = pkg.mod.add_one(41) +assert result == 42 +""" + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) + assert state.warnings == [] + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_deeply_nested_dotted_import(self, fprime_test_api, tmp_path): + """`import a.b.c` resolves `a/b/c.fpy` (arbitrary nesting depth).""" + _write_module( + tmp_path, + "a.b.c", + """\ +def val() -> U32: + return 7 +""", + ) + main = """\ +import a.b.c + +x: U32 = a.b.c.val() +assert x == 7 +""" + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_dotted_symbol_requires_full_path(self, fprime_test_api, tmp_path): + """A member of `pkg.mod` is only reachable as `pkg.mod.f`, never as a + bare `f` nor via a truncated `mod.f`.""" + _write_module( + tmp_path, + "pkg.mod", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +import pkg.mod + +y: U32 = mod.f() +""" + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_missing_leaf_in_existing_package_is_error( + self, fprime_test_api, tmp_path + ): + """The package dir exists but the leaf module file does not.""" + _write_module( + tmp_path, + "pkg.other", + """\ +def f() -> U32: + return 1 +""", + ) + main = """\ +import pkg.missing +""" + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_two_modules_in_same_package_no_collision( + self, fprime_test_api, tmp_path + ): + """Sibling modules under one package are independently namespaced.""" + _write_module(tmp_path, "pkg.a", "def f() -> U32:\n return 1\n") + _write_module(tmp_path, "pkg.b", "def f() -> U32:\n return 2\n") + main = """\ +import pkg.a +import pkg.b + +x: U32 = pkg.a.f() +y: U32 = pkg.b.f() +assert x == 1 +assert y == 2 +""" + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + +class TestImportPackagePrecedence: + """File-vs-directory precedence follows Python's namespace-package rules: + a module file outranks a same-named (init-less) package directory at a + leaf, but non-leaf segments always descend into the directory.""" + + def test_module_file_beats_namespace_directory(self, fprime_test_api, tmp_path): + """`foo.fpy` and a `foo/` directory both exist; `import foo` resolves + the module file (namespace dirs rank below modules).""" + _write_module(tmp_path, "foo", "def f() -> U32:\n return 1\n") + # This also creates the sibling `foo/` directory: + _write_module(tmp_path, "foo.inner", "def g() -> U32:\n return 2\n") + main = """\ +import foo + +x: U32 = foo.f() +assert x == 1 +""" + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_package_dir_used_for_dotted_descent(self, fprime_test_api, tmp_path): + """A `pkg.fpy` module does not block `import pkg.mod` from descending + into the `pkg/` directory to reach `pkg/mod.fpy`.""" + _write_module(tmp_path, "pkg", "def top() -> U32:\n return 1\n") + _write_module(tmp_path, "pkg.mod", "def f() -> U32:\n return 5\n") + main = """\ +import pkg.mod + +x: U32 = pkg.mod.f() +assert x == 5 +""" + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_bare_package_import_is_error(self, fprime_test_api, tmp_path): + """`import pkg` where only a `pkg/` directory exists (no `pkg.fpy`) is + an error -- a namespace package has nothing to inline.""" + # Creates `pkg/mod.fpy`, so `pkg/` exists as a directory but `pkg.fpy` + # does not. + _write_module(tmp_path, "pkg.mod", "def f() -> U32:\n return 1\n") + main = """\ +import pkg +""" + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + def test_dotted_leaf_package_import_is_error(self, fprime_test_api, tmp_path): + """`import a.b` where `a/b/` is a directory but `a/b.fpy` does not exist + is likewise an error at the dotted leaf.""" + _write_module(tmp_path, "a.b.c", "def f() -> U32:\n return 1\n") + main = """\ +import a.b +""" + assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + + +class TestImportSearchDirs: + """`import_search_dirs` is an ordered search path: first match wins.""" + + def test_module_found_in_later_search_dir(self, fprime_test_api, tmp_path): + """A module present only in the second search dir is still found.""" + d1 = tmp_path / "d1" + d2 = tmp_path / "d2" + d1.mkdir() + d2.mkdir() + _write_module(d2, "lib", "def f() -> U32:\n return 9\n") + main = """\ +import lib + +x: U32 = lib.f() +assert x == 9 +""" + assert_run_success( + fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] + ) + + def test_first_search_dir_shadows_later(self, fprime_test_api, tmp_path): + """When a module name exists in two search dirs, the earlier dir wins.""" + d1 = tmp_path / "d1" + d2 = tmp_path / "d2" + d1.mkdir() + d2.mkdir() + _write_module(d1, "lib", "def f() -> U32:\n return 1\n") + _write_module(d2, "lib", "def f() -> U32:\n return 2\n") + main = """\ +import lib + +x: U32 = lib.f() +assert x == 1 +""" + assert_run_success( + fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] + ) + + def test_search_order_respects_dir_order(self, fprime_test_api, tmp_path): + """Reversing the search-dir order flips which module wins.""" + d1 = tmp_path / "d1" + d2 = tmp_path / "d2" + d1.mkdir() + d2.mkdir() + _write_module(d1, "lib", "def f() -> U32:\n return 1\n") + _write_module(d2, "lib", "def f() -> U32:\n return 2\n") + main = """\ +import lib + +x: U32 = lib.f() +assert x == 2 +""" + assert_run_success( + fprime_test_api, main, import_search_dirs=[str(d2), str(d1)] + ) + + def test_dotted_module_resolved_across_search_dirs( + self, fprime_test_api, tmp_path + ): + """Dotted resolution honors the search path: `pkg/mod.fpy` lives only in + the second dir.""" + d1 = tmp_path / "d1" + d2 = tmp_path / "d2" + d1.mkdir() + d2.mkdir() + _write_module(d2, "pkg.mod", "def f() -> U32:\n return 5\n") + main = """\ +import pkg.mod + +x: U32 = pkg.mod.f() +assert x == 5 +""" + assert_run_success( + fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] + ) + + def test_no_search_dirs_cannot_resolve(self, fprime_test_api, tmp_path): + """With an empty search path, no import can resolve.""" + _write_module(tmp_path, "lib", "def f() -> U32:\n return 1\n") + main = """\ +import lib """ - assert_compile_failure(fprime_test_api, main, import_dir=str(tmp_path)) + assert_compile_failure(fprime_test_api, main, import_search_dirs=[]) class TestImportVariables: @@ -626,8 +869,8 @@ def get() -> U32: """ # The top-level assignment runs at sequence start -> side effect warning, # but `withvar.counter` still resolves as a namespaced symbol. - state, _, _ = compile_seq(main, import_dir=str(tmp_path)) + state, _, _ = compile_seq(main, import_search_dirs=[str(tmp_path)]) assert any( w.type == WarningType.IMPORT_SIDE_EFFECTS for w in state.warnings ), f"expected an import-side-effects warning, got {state.warnings}" - assert_run_success(fprime_test_api, main, import_dir=str(tmp_path)) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index aae8e35..9289729 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -94,6 +94,82 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): assert captured_kwargs["ground_binary_dir"] == str(input_path.parent.resolve()) +def _run_compile_capturing_kwargs(monkeypatch, argv): + """Invoke compile_main with the codegen chain stubbed out, returning the + kwargs get_base_compile_state was called with.""" + monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + + captured_kwargs = {} + + def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): + captured_kwargs.update(kwargs) + return "STATE" + + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + ) + monkeypatch.setattr( + fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) + ) + + fpy_main.compile_main(argv) + return captured_kwargs + + +def test_compile_main_include_dirs_after_input_parent(monkeypatch, tmp_path): + """-i/--include dirs are appended, resolved, after the input file's own dir, + which is always searched first.""" + input_path = tmp_path / "sub" / "seq.fpy" + input_path.parent.mkdir() + input_path.write_text("content") + dict_path = tmp_path / "dict.json" + dict_path.write_text("{}") + inc_a = tmp_path / "inc_a" + inc_b = tmp_path / "inc_b" + inc_a.mkdir() + inc_b.mkdir() + + captured_kwargs = _run_compile_capturing_kwargs( + monkeypatch, + [ + str(input_path), + "--dictionary", + str(dict_path), + "-i", + str(inc_a), + "--include", + str(inc_b), + ], + ) + + assert captured_kwargs["import_search_dirs"] == [ + str(input_path.parent.resolve()), + str(inc_a.resolve()), + str(inc_b.resolve()), + ] + + +def test_compile_main_include_defaults_to_input_parent_only(monkeypatch, tmp_path): + """With no -i flags, the search path is just the input file's own dir.""" + input_path = tmp_path / "seq.fpy" + input_path.write_text("content") + dict_path = tmp_path / "dict.json" + dict_path.write_text("{}") + + captured_kwargs = _run_compile_capturing_kwargs( + monkeypatch, + [ + str(input_path), + "--dictionary", + str(dict_path), + ], + ) + + assert captured_kwargs["import_search_dirs"] == [str(input_path.parent.resolve())] + + def test_compile_main_missing_input(tmp_path, capsys): missing = tmp_path / "missing.fpy" dict_path = tmp_path / "dict.json" From 93632d2fa1f8be80175fd73e02a4d28640e10e07 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Sun, 5 Jul 2026 10:12:35 -0400 Subject: [PATCH 04/10] Combine warnings and errors files --- src/fpy/error.py | 55 +++++++++++++++++++++++++++++++++++ src/fpy/main.py | 2 +- src/fpy/semantics.py | 2 +- src/fpy/state.py | 3 +- src/fpy/warnings.py | 60 --------------------------------------- test/fpy/test_imports.py | 2 +- test/fpy/test_warnings.py | 2 +- 7 files changed, 60 insertions(+), 66 deletions(-) delete mode 100644 src/fpy/warnings.py diff --git a/src/fpy/error.py b/src/fpy/error.py index e5f6390..47cf006 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -1,5 +1,6 @@ # compiler debug flag from dataclasses import dataclass +from enum import Enum import sys import traceback from typing import Any @@ -190,6 +191,60 @@ def __str__(self) -> str: return f"{header}\n {self.detail}" +class WarningType(str, Enum): + """The set of diagnostics the compiler may warn about.""" + + EMPTY_RANGE = "empty-range" + IMPORT_SIDE_EFFECTS = "import-side-effects" + + @classmethod + def from_value(cls, value: str) -> "WarningType": + """Look up a WarningType by its CLI string value. + + Raises ValueError (listing the valid values) if unknown.""" + for member in cls: + if member.value == value: + return member + valid = ", ".join(m.value for m in cls) + raise ValueError(f"Unknown warning type {value!r}. Valid types: {valid}") + + +# Sentinel accepted by --ignore / --error to mean "every warning type". +WARNING_ALL = "all" + + +def parse_warning_set(spec: str) -> set[WarningType]: + """Parse a comma-separated `--ignore`/`--error` spec into a set of types. + + Empty/whitespace-only entries are skipped. The special value "all" + expands to every WarningType. Raises ValueError on an unknown type.""" + result: set[WarningType] = set() + for raw in spec.split(","): + token = raw.strip() + if not token: + continue + if token == WARNING_ALL: + result.update(WarningType) + continue + result.add(WarningType.from_value(token)) + return result + + +@dataclass +class CompileWarning: + """A non-fatal diagnostic.""" + + type: WarningType + msg: str + node: Any = None + + def __str__(self) -> str: + label = f"warning [{self.type.value}]: {self.msg}" + return format_diagnostic(label, self.node, color=Colors.yellow) + + __repr__ = __str__ + + def handle_lark_error(err): import sys assert isinstance(err, LarkError), err diff --git a/src/fpy/main.py b/src/fpy/main.py index 159fbf3..12fefbb 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -36,7 +36,7 @@ from fpy.codegen_llvm import backend_version_str from fpy.dictionary import load_dictionary from fpy.state import get_base_compile_state -from fpy.warnings import parse_warning_set +from fpy.error import parse_warning_set def human_readable_size(size_bytes): diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 94aa3f6..2a37ec7 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -49,7 +49,7 @@ CompileState, ForLoopAnalysis, ) -from fpy.warnings import WarningType +from fpy.error import WarningType from fpy.symbols import ( BuiltinFuncSymbol, CallableSymbol, diff --git a/src/fpy/state.py b/src/fpy/state.py index 583e2a3..f1597ef 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -4,8 +4,7 @@ from dataclasses import dataclass, field from fpy.dictionary import json_default_to_fpy_value, load_dictionary -from fpy.error import CompileError, DictionaryError -from fpy.warnings import CompileWarning, WarningType +from fpy.error import CompileError, CompileWarning, DictionaryError, WarningType from fpy.ir import Ir, IrLabel from fpy.macros import MACROS from fpy.symbols import CallableSymbol, CastSymbol, CommandSymbol, Symbol, SymbolTable, TypeCtorSymbol, VariableSymbol, create_symbol_table, merge_symbol_tables diff --git a/src/fpy/warnings.py b/src/fpy/warnings.py deleted file mode 100644 index c3cfa7b..0000000 --- a/src/fpy/warnings.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass -from enum import Enum -from typing import Any - -from fpy.error import Colors, format_diagnostic - - -class WarningType(str, Enum): - """The set of diagnostics the compiler may warn about.""" - - EMPTY_RANGE = "empty-range" - IMPORT_SIDE_EFFECTS = "import-side-effects" - - @classmethod - def from_value(cls, value: str) -> "WarningType": - """Look up a WarningType by its CLI string value. - - Raises ValueError (listing the valid values) if unknown.""" - for member in cls: - if member.value == value: - return member - valid = ", ".join(m.value for m in cls) - raise ValueError(f"Unknown warning type {value!r}. Valid types: {valid}") - - -# Sentinel accepted by --ignore / --error to mean "every warning type". -WARNING_ALL = "all" - - -def parse_warning_set(spec: str) -> set[WarningType]: - """Parse a comma-separated `--ignore`/`--error` spec into a set of types. - - Empty/whitespace-only entries are skipped. The special value "all" - expands to every WarningType. Raises ValueError on an unknown type.""" - result: set[WarningType] = set() - for raw in spec.split(","): - token = raw.strip() - if not token: - continue - if token == WARNING_ALL: - result.update(WarningType) - continue - result.add(WarningType.from_value(token)) - return result - - -@dataclass -class CompileWarning: - """A non-fatal diagnostic.""" - - type: WarningType - msg: str - node: Any = None - - def __str__(self) -> str: - label = f"warning [{self.type.value}]: {self.msg}" - return format_diagnostic(label, self.node, color=Colors.yellow) - - __repr__ = __str__ diff --git a/test/fpy/test_imports.py b/test/fpy/test_imports.py index 3c63ac6..4bc331b 100644 --- a/test/fpy/test_imports.py +++ b/test/fpy/test_imports.py @@ -51,7 +51,7 @@ assert_run_success, compile_seq, ) -from fpy.warnings import WarningType +from fpy.error import WarningType # The whole module targets not-yet-implemented behavior. pytestmark = pytest.mark.xfail( diff --git a/test/fpy/test_warnings.py b/test/fpy/test_warnings.py index aeaee1c..f7d7044 100644 --- a/test/fpy/test_warnings.py +++ b/test/fpy/test_warnings.py @@ -8,7 +8,7 @@ import pytest from fpy.test_helpers import CompilationFailed, compile_seq -from fpy.warnings import ( +from fpy.error import ( WARNING_ALL, CompileWarning, WarningType, From fc89bc67c3049b70ea6955706e247ea631a5a44f Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Sun, 5 Jul 2026 11:30:03 -0400 Subject: [PATCH 05/10] Reformat most files --- src/fpy/bytecode/assembler.py | 20 +- src/fpy/bytecode/directives.py | 21 +- src/fpy/codegen_fpybc.py | 137 +++--- src/fpy/codegen_llvm.py | 44 +- src/fpy/compiler.py | 16 +- src/fpy/desugaring.py | 232 ++++++---- src/fpy/dictionary.py | 1 - src/fpy/error.py | 10 +- src/fpy/ir.py | 3 +- src/fpy/macros.py | 6 +- src/fpy/main.py | 13 +- src/fpy/model.py | 48 +- src/fpy/semantics.py | 8 +- src/fpy/state.py | 17 +- src/fpy/symbols.py | 3 +- src/fpy/syntax.py | 59 ++- src/fpy/test_helpers.py | 27 +- src/fpy/types.py | 6 +- src/fpy/visitors.py | 4 +- test/conftest.py | 2 +- test/fpy/test_anon_exprs.py | 10 +- test/fpy/test_arithmetic.py | 49 +- test/fpy/test_assembler.py | 202 ++++++-- test/fpy/test_boolean_logic.py | 3 + test/fpy/test_check.py | 14 +- test/fpy/test_comparisons.py | 30 +- test/fpy/test_compiler_config.py | 139 +++--- test/fpy/test_control_flow.py | 6 + test/fpy/test_depend.py | 1 + test/fpy/test_dictionary.py | 581 ++++++++++++++++++++---- test/fpy/test_functions.py | 28 +- test/fpy/test_golden.py | 19 +- test/fpy/test_imports.py | 173 +++++-- test/fpy/test_integration.py | 6 +- test/fpy/test_logging.py | 45 +- test/fpy/test_main.py | 156 +++++-- test/fpy/test_model.py | 14 +- test/fpy/test_parsing.py | 2 + test/fpy/test_rng.py | 33 +- test/fpy/test_seq_calling.py | 66 ++- test/fpy/test_sequence_metadata.py | 29 +- test/fpy/test_telemetry.py | 5 +- test/fpy/test_time_seqs.py | 18 +- test/fpy/test_types_and_constructors.py | 20 +- test/fpy/test_types_visitors.py | 2 + test/fpy/test_variables.py | 2 + test/fpy/test_warnings.py | 20 +- test/fpy/test_wasm.py | 93 ++-- 48 files changed, 1694 insertions(+), 749 deletions(-) diff --git a/src/fpy/bytecode/assembler.py b/src/fpy/bytecode/assembler.py index bfcc173..5217d24 100644 --- a/src/fpy/bytecode/assembler.py +++ b/src/fpy/bytecode/assembler.py @@ -101,7 +101,8 @@ def wrapper(self, tree): def handle_str(meta, s: str): return s.strip("'").strip('"') -def handle_bytes(meta, value: list[int|str]|None): + +def handle_bytes(meta, value: list[int | str] | None): if value is None: return bytes() @@ -231,6 +232,7 @@ def fpybc_directives_to_fpyasm(dirs: list[Directive]) -> str: def _get_version_tuple() -> tuple[int, int, int]: try: import re + v = version("fprime-fpy") # Handle versions like "0.0.1a3.dev103+g244fdeadc" # Extract just the major.minor.patch part @@ -273,7 +275,9 @@ def pack(self) -> bytes: @staticmethod def unpack(data: bytes) -> Header: - (major, minor, patch, schema, arg_count, stmt_count, body_size) = struct.unpack_from(HEADER_FORMAT, data) + major, minor, patch, schema, arg_count, stmt_count, body_size = ( + struct.unpack_from(HEADER_FORMAT, data) + ) return Header(major, minor, patch, schema, arg_count, stmt_count, body_size) @@ -302,7 +306,9 @@ def _serialize_arg_specs(arg_specs: list[tuple[str, str, int]]) -> bytes: return result -def _deserialize_arg_specs(data: bytes, offset: int, count: int) -> tuple[int, list[tuple[str, str, int]]]: +def _deserialize_arg_specs( + data: bytes, offset: int, count: int +) -> tuple[int, list[tuple[str, str, int]]]: """Deserialize arg specs from (arg_name, type_name, size) triples. Returns (new_offset, list_of_(arg_name, type_name, size)_tuples).""" specs = [] @@ -314,7 +320,9 @@ def _deserialize_arg_specs(data: bytes, offset: int, count: int) -> tuple[int, l return offset, specs -def deserialize_directives(data: bytes) -> tuple[list[Directive], list[tuple[str, str, int]]]: +def deserialize_directives( + data: bytes, +) -> tuple[list[Directive], list[tuple[str, str, int]]]: header = _unpack_and_check_header(data) # Deserialize arg specs section (immediately after fixed header) @@ -403,7 +411,9 @@ def serialize_directives( if arg_specs is None: arg_specs = [] - assert len(arg_specs) <= 255, f"Too many sequence arguments ({len(arg_specs)}); should have been caught by CheckSeqRunArgs" + assert ( + len(arg_specs) <= 255 + ), f"Too many sequence arguments ({len(arg_specs)}); should have been caught by CheckSeqRunArgs" body_bytes = bytes() diff --git a/src/fpy/bytecode/directives.py b/src/fpy/bytecode/directives.py index 98372d4..e980d38 100644 --- a/src/fpy/bytecode/directives.py +++ b/src/fpy/bytecode/directives.py @@ -52,14 +52,13 @@ def _update_configurable_type( target: FpyType, type_defs: dict[str, FpyType], name: str ) -> None: - """Update *target* in place to match the dictionary's definition of *name*. - """ + """Update *target* in place to match the dictionary's definition of *name*.""" if name not in type_defs: return resolved = type_defs[name] - assert resolved.is_primitive, ( - f"Configurable type {name} must resolve to a primitive, got {resolved}" - ) + assert ( + resolved.is_primitive + ), f"Configurable type {name} must resolve to a primitive, got {resolved}" target.kind = resolved.kind target.name = resolved.name @@ -76,6 +75,7 @@ def update_configurable_types_from_dict(type_defs: dict[str, FpyType]) -> None: # DirectiveId enum # ───────────────────────────────────────────────────────────────────────────── + class DirectiveId(Enum): INVALID = 0 WAIT_REL = 1 @@ -212,9 +212,9 @@ def serialize_args(self) -> bytes: # Look up the FpyType for this field and serialize fpy_type = self._FIELD_TYPES.get(f.name) - assert fpy_type is not None, ( - f"No type mapping for field {f.name} in {type(self).__name__}" - ) + assert ( + fpy_type is not None + ), f"No type mapping for field {f.name} in {type(self).__name__}" output += FpyValue(fpy_type, value).serialize() return output @@ -270,6 +270,7 @@ def deserialize(cls, data: bytes, offset: int) -> tuple[int, Directive] | None: @dataclass class StackOpDirective(Directive): """Base for directives that operate on the expression stack.""" + pass @@ -654,6 +655,7 @@ class FloatExtendDirective(StackOpDirective): @dataclass class FloatFloorDirective(StackOpDirective): """Floor a float toward -inf (used to lower float `//`).""" + opcode: ClassVar[DirectiveId] = DirectiveId.FFLOOR @@ -661,12 +663,14 @@ class FloatFloorDirective(StackOpDirective): class IntAbsDirective(StackOpDirective): """Absolute value of a signed I64. abs(I64 min) wraps to I64 min, matching libm's llabs and LLVM's llvm.abs (no overflow trap).""" + opcode: ClassVar[DirectiveId] = DirectiveId.IABS @dataclass class FloatAbsDirective(StackOpDirective): """Absolute value of an F64 (matching llvm.fabs).""" + opcode: ClassVar[DirectiveId] = DirectiveId.FABS @@ -730,6 +734,7 @@ class PushRandDirective(Directive): class SetSeedDirective(Directive): opcode: ClassVar[DirectiveId] = DirectiveId.SET_SEED + @dataclass class CallDirective(Directive): opcode: ClassVar[DirectiveId] = DirectiveId.CALL diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index ef2895f..258dae1 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -140,7 +140,7 @@ class CollectUsedFunctions(Visitor): """Collects the set of functions that are called anywhere in the code. - + Any function that is called (even from within other functions) will be marked as used and have code generated for it. """ @@ -217,12 +217,12 @@ def visit_AstDef(self, node: AstDef, state: CompileState): return entry_label = state.func_entry_labels[node] code = [entry_label] - + # Allocate space for local variables lvar_array_size_bytes = state.frame_sizes[node.body] if lvar_array_size_bytes > 0: code.append(AllocateDirective(lvar_array_size_bytes)) - + code.extend(GenerateFunctionBody().emit(node.body, state)) func = state.resolved_symbols[node.name] if func.return_type is NOTHING and not state.does_return[node.body]: @@ -258,8 +258,7 @@ def _emit_cmd_arg( accounting so it matches the bytes actually placed on the stack. """ const_val = ( - arg if isinstance(arg, FpyValue) - else state.const_expr_values.get(arg) + arg if isinstance(arg, FpyValue) else state.const_expr_values.get(arg) ) if const_val is not None: serialized = const_val.serialize() @@ -374,7 +373,9 @@ def try_emit_expr_as_const( return None assert isinstance(expr_value, FpyValue) and expr_value.type not in ( - INTEGER, INTERNAL_STRING, FLOAT + INTEGER, + INTERNAL_STRING, + FLOAT, ), expr_value if expr_value is NOTHING_VALUE: @@ -400,13 +401,19 @@ def discard_expr_result(self, node: Ast, state: CompileState) -> list[Directive] return [DiscardDirective(result_type.max_size)] return [] - def assert_cmd_response_ok(self, node: AstFuncCall, state: CompileState) -> list[Directive | Ir]: + def assert_cmd_response_ok( + self, node: AstFuncCall, state: CompileState + ) -> list[Directive | Ir]: """For a bare command call, emit code to check the response and exit if it is not OK and the flags.assert_cmd_success variable is set.""" dirs: list[Directive | Ir] = [] end_label = IrLabel(node, "cmd_ok") # compare response on stack to Fw.CmdResponse.OK - dirs.append(PushValDirective(FpyValue(CMD_RESPONSE, CMD_RESPONSE.enum_dict["OK"]).serialize())) + dirs.append( + PushValDirective( + FpyValue(CMD_RESPONSE, CMD_RESPONSE.enum_dict["OK"]).serialize() + ) + ) dirs.append(MemCompareDirective(CMD_RESPONSE.max_size)) # now stack has True if response == OK # if response was OK, skip to end, otherwise go to "cmd_not_ok" @@ -559,7 +566,9 @@ def calc_lvar_offset_of_array_element( # we want to peek the index so we can consume it for the oob check # byte count dirs.append( - PushValDirective(FpyValue(StackSizeType, ArrayIndexType.max_size).serialize()) + PushValDirective( + FpyValue(StackSizeType, ArrayIndexType.max_size).serialize() + ) ) # offset dirs.append(PushValDirective(FpyValue(StackSizeType, 0).serialize())) @@ -574,16 +583,16 @@ def calc_lvar_offset_of_array_element( # okay now dupe index again to check < 0 # byte count dirs.append( - PushValDirective(FpyValue(StackSizeType, ArrayIndexType.max_size).serialize()) + PushValDirective( + FpyValue(StackSizeType, ArrayIndexType.max_size).serialize() + ) ) # offset is 1 because we currently have the result of the last check on stack dirs.append(PushValDirective(FpyValue(StackSizeType, 1).serialize())) dirs.append(PeekDirective()) # duplicate the index # convert idx to i64 dirs.extend(self.convert_numeric_type(ArrayIndexType, I64)) - dirs.append( - PushValDirective(FpyValue(I64, 0).serialize()) - ) # push 0 as i64 + dirs.append(PushValDirective(FpyValue(I64, 0).serialize())) # push 0 as i64 # check if idx < 0 dirs.append(SignedLessThanDirective()) # or both checks together @@ -602,17 +611,16 @@ def calc_lvar_offset_of_array_element( # okay we're good. should still have the idx on the stack # multiply the index by the member type size - dirs.append(PushValDirective(FpyValue(U64, array_type.elem_type.max_size).serialize())) + dirs.append( + PushValDirective(FpyValue(U64, array_type.elem_type.max_size).serialize()) + ) dirs.append(IntMultiplyDirective()) return dirs def _is_cmd_and_response_unhandled(self, stmt: Ast, state: CompileState) -> bool: """True when *stmt* is a command call whose response is not captured.""" - return ( - is_instance_compat(stmt, AstFuncCall) - and is_instance_compat( - state.resolved_symbols.get(stmt.func), CommandSymbol - ) + return is_instance_compat(stmt, AstFuncCall) and is_instance_compat( + state.resolved_symbols.get(stmt.func), CommandSymbol ) def _should_lower_stmt(self, stmt: Ast, state: CompileState) -> bool: @@ -780,7 +788,9 @@ def emit_AstIndexExpr(self, node: AstIndexExpr, state: CompileState): # Direct index access on anonymous array literal. # The index must be a compile-time constant. idx_value = state.const_expr_values.get(node.item) - assert idx_value is not None, "Dynamic indexing on anonymous array literals is not supported" + assert ( + idx_value is not None + ), "Dynamic indexing on anonymous array literals is not supported" idx = idx_value.val assert 0 <= idx < len(node.parent.elements), f"Index {idx} out of bounds" dirs = self.emit(node.parent.elements[idx], state) @@ -814,9 +824,7 @@ def emit_AstIndexExpr(self, node: AstIndexExpr, state: CompileState): # get the member from the stack at this offset, discard the rest of # the parent dirs.append( - GetFieldDirective( - parent_type.max_size, parent_type.elem_type.max_size - ) + GetFieldDirective(parent_type.max_size, parent_type.elem_type.max_size) ) # now convert the type if necessary @@ -875,9 +883,7 @@ def emit_AstGetAttr(self, node: AstGetAttr, state: CompileState): # Use global directives only when inside a function AND accessing a global variable use_global = self.in_function and sym.is_global if use_global: - dirs.append( - LoadAbsDirective(sym.frame_offset, sym.type.max_size) - ) + dirs.append(LoadAbsDirective(sym.frame_offset, sym.type.max_size)) else: dirs.append(LoadRelDirective(sym.frame_offset, sym.type.max_size)) elif is_instance_compat(sym, FieldAccess): @@ -897,12 +903,14 @@ def emit_AstGetAttr(self, node: AstGetAttr, state: CompileState): # use the converted type of parent parent_type = state.contextual_types[sym.parent_expr] # push the offset to the stack - dirs.append(PushValDirective(FpyValue(StackSizeType, sym.local_offset).serialize())) dirs.append( - GetFieldDirective( - parent_type.max_size, unconverted_type.max_size + PushValDirective( + FpyValue(StackSizeType, sym.local_offset).serialize() ) ) + dirs.append( + GetFieldDirective(parent_type.max_size, unconverted_type.max_size) + ) else: assert ( False @@ -937,9 +945,7 @@ def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): dirs.append(MemCompareDirective(lhs_type.max_size)) if node.op == BinaryStackOp.NOT_EQUAL: dirs.append(NotDirective()) - elif ( - node.op == BinaryStackOp.FLOOR_DIVIDE and intermediate_type == F64 - ): + elif node.op == BinaryStackOp.FLOOR_DIVIDE and intermediate_type == F64: # float floor division: divide, then floor toward -inf dirs.append(FloatDivideDirective()) dirs.append(FloatFloorDirective()) @@ -953,8 +959,13 @@ def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): # The VM operates on 64-bit values, so after the op we have a 64-bit result. # Convert from the 64-bit intermediate type to the synthesized result type. synthesized_type = state.synthesized_types[node] - if intermediate_type in SPECIFIC_NUMERIC_TYPES and synthesized_type in SPECIFIC_NUMERIC_TYPES: - dirs.extend(self.convert_numeric_type(intermediate_type, synthesized_type)) + if ( + intermediate_type in SPECIFIC_NUMERIC_TYPES + and synthesized_type in SPECIFIC_NUMERIC_TYPES + ): + dirs.extend( + self.convert_numeric_type(intermediate_type, synthesized_type) + ) # and convert the result of the op into the desired result of this expr unconverted_type = state.synthesized_types[node] @@ -1017,7 +1028,10 @@ def emit_AstUnaryOp(self, node: AstUnaryOp, state: CompileState): # The VM operates on 64-bit values, so after the op we have a 64-bit result. # Convert from the 64-bit intermediate type to the synthesized result type. synthesized_type = state.synthesized_types[node] - if intermediate_type in SPECIFIC_NUMERIC_TYPES and synthesized_type in SPECIFIC_NUMERIC_TYPES: + if ( + intermediate_type in SPECIFIC_NUMERIC_TYPES + and synthesized_type in SPECIFIC_NUMERIC_TYPES + ): dirs.extend(self.convert_numeric_type(intermediate_type, synthesized_type)) # and convert the result of the op into the desired result of this expr @@ -1040,15 +1054,19 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState): dirs = self._emit_seq_run_cmd(node, func, state) elif is_instance_compat(func, CommandSymbol): const_args = all( - isinstance(arg_node, FpyValue) or - (state.const_expr_values[arg_node] is not None) + isinstance(arg_node, FpyValue) + or (state.const_expr_values[arg_node] is not None) for arg_node in node_args ) if const_args: # can just hardcode this cmd arg_bytes = bytes() for arg_node in node_args: - arg_value = arg_node if isinstance(arg_node, FpyValue) else state.const_expr_values[arg_node] + arg_value = ( + arg_node + if isinstance(arg_node, FpyValue) + else state.const_expr_values[arg_node] + ) arg_bytes += arg_value.serialize() dirs.append(ConstCmdDirective(func.cmd.opcode, arg_bytes)) else: @@ -1061,7 +1079,9 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState): arg_byte_count += actual_size # then push cmd opcode to stack as u32 dirs.append( - PushValDirective(FpyValue(FwOpcodeType, func.cmd.opcode).serialize()) + PushValDirective( + FpyValue(FwOpcodeType, func.cmd.opcode).serialize() + ) ) # now that all args are pushed to the stack, pop them and opcode off the stack # as a command @@ -1071,8 +1091,14 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState): const_arg_values: dict[int, FpyValue] = {} for i in func.const_arg_indices: arg = node_args[i] - const_val = arg if isinstance(arg, FpyValue) else state.const_expr_values.get(arg) - assert const_val is not None, f"const arg {i} of {func.name} should have been validated by semantics" + const_val = ( + arg + if isinstance(arg, FpyValue) + else state.const_expr_values.get(arg) + ) + assert ( + const_val is not None + ), f"const arg {i} of {func.name} should have been validated by semantics" const_arg_values[i] = const_val # put non-const arg values on stack @@ -1136,7 +1162,10 @@ def _compute_field_access_offset( parent_type = state.contextual_types[current.parent_expr] const_idx = state.const_expr_values.get(current.idx_expr) if const_idx is not None: - assert isinstance(const_idx, FpyValue) and const_idx.type == ArrayIndexType + assert ( + isinstance(const_idx, FpyValue) + and const_idx.type == ArrayIndexType + ) const_offset += const_idx.val * parent_type.elem_type.max_size else: dynamic_components.append((current.idx_expr, parent_type)) @@ -1166,7 +1195,9 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState): is_global_var = lhs.base_sym.is_global # Walk the field access chain to compute the total offset. - field_const_offset, dynamic_components = self._compute_field_access_offset(lhs, state) + field_const_offset, dynamic_components = self._compute_field_access_offset( + lhs, state + ) # Use global directives only when inside a function AND accessing a global variable use_global = self.in_function and is_global_var @@ -1179,15 +1210,11 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState): frame_offset = base_frame_offset + field_const_offset if use_global: dirs.append( - StoreAbsConstOffsetDirective( - frame_offset, lhs.type.max_size - ) + StoreAbsConstOffsetDirective(frame_offset, lhs.type.max_size) ) else: dirs.append( - StoreRelConstOffsetDirective( - frame_offset, lhs.type.max_size - ) + StoreRelConstOffsetDirective(frame_offset, lhs.type.max_size) ) else: # At least one array index in the access chain is not known at @@ -1209,9 +1236,7 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState): # Add the constant part: base variable's frame offset + # accumulated constant field offsets. const_part = base_frame_offset + field_const_offset - dirs.append( - PushValDirective(FpyValue(U64, const_part).serialize()) - ) + dirs.append(PushValDirective(FpyValue(U64, const_part).serialize())) dirs.append(IntAddDirective()) # and now convert the u64 back into the SignedStackSizeType that store expects @@ -1298,7 +1323,7 @@ def emit_AstBlock(self, node: AstBlock, state: CompileState): assert state.flags_var.frame_offset == args_size flags_default = FpyValue(flags_type, dict(flags_type.member_defaults)) main_body.append(PushValDirective(flags_default.serialize())) - + # we can calc how much space the user-defined lvars take by subtracting # the sequence args size, and the flags size, from the frame size @@ -1308,7 +1333,7 @@ def emit_AstBlock(self, node: AstBlock, state: CompileState): # allocate space for local variables if remaining > 0: main_body.append(AllocateDirective(remaining)) - + # generate the main function using GenerateTopLevel (not in a function context) main_body.extend(GenerateTopLevel().emit(node, state)) @@ -1372,7 +1397,9 @@ def run(self, ir, state: CompileState): label = dir.label.name if label not in labels: return BackendError(f"Unknown label {label}") - dirs.append(PushValDirective(FpyValue(StackSizeType, labels[label]).serialize())) + dirs.append( + PushValDirective(FpyValue(StackSizeType, labels[label]).serialize()) + ) else: dirs.append(dir) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index aba8bab..6dd83f7 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -33,7 +33,6 @@ from fpy.types import FpyValue, is_instance_compat from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor - LLVM_TRIPLE = "wasm32-unknown-unknown" # Target the original WebAssembly 1.0 MVP (the W3C Core Spec 1.0) @@ -44,7 +43,7 @@ # TODO strip custom sections from WASM--wasm opt crate, optimize for size? -# TODO with wasm mvp llvm will provide pow/fmod?? +# TODO with wasm mvp llvm will provide pow/fmod?? # TODO could just start with a .a and a header?? @@ -155,8 +154,11 @@ def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: # Enums and bools lower to integers too, so any integer-typed value # (not just numeric types) compares with icmp; aggregates don't. if isinstance(lhs.type, ir.IntType): - return b.icmp_signed(op, lhs, rhs) if is_signed \ + return ( + b.icmp_signed(op, lhs, rhs) + if is_signed else b.icmp_unsigned(op, lhs, rhs) + ) raise BackendError( f"LLVM backend can't compare values of type " f"'{intermediate_type.display_name}' yet" @@ -178,9 +180,7 @@ def _emit_floor_divide( # llvm.floor intrinsic lowers to a native f64.floor on wasm (no # libcall), so e.g. -5.5 / 2.0 = -2.75 floors to -3.0. quotient = b.fdiv(lhs, rhs) - floor_fn = b.module.declare_intrinsic( - "llvm.floor", [quotient.type] - ) + floor_fn = b.module.declare_intrinsic("llvm.floor", [quotient.type]) return b.call(floor_fn, [quotient]) if not is_signed: # Unsigned operands are non-negative, so the exact quotient is too; @@ -241,9 +241,7 @@ def _emit_modulo( corrected = b.add(rem, rhs) return b.select(b.and_(nonzero, signs_differ), corrected, rem) - def _emit_short_circuit( - self, node: AstBinaryOp, state: CompileState - ) -> ir.Value: + def _emit_short_circuit(self, node: AstBinaryOp, state: CompileState) -> ir.Value: """Lower ``and``/``or`` with short-circuit evaluation.""" b = self.builder bool_type = ir.IntType(1) @@ -300,10 +298,12 @@ def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: # synthesized type; emit() handles any widening to the contextual type. return self.builder.load(sym.llvm_ptr, name=str(node.name)) - def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState) -> ir.Value | None: + def emit_AstFuncCall( + self, node: AstFuncCall, state: CompileState + ) -> ir.Value | None: func = state.resolved_symbols[node.func] if isinstance(func, CastSymbol): - # the actual conversion happens already as part of the + # the actual conversion happens already as part of the # synthesized -> contextual conversion return self.emit(node.args[0], state) if not isinstance(func, BuiltinFuncSymbol): @@ -336,7 +336,9 @@ def convert_numeric_type(self, value: ir.Value, from_type, to_type) -> ir.Value: # Same width: signedness isn't part of an LLVM integer type. return value if from_type.is_integer: # int -> float - to_float = self.builder.sitofp if from_type.is_signed else self.builder.uitofp + to_float = ( + self.builder.sitofp if from_type.is_signed else self.builder.uitofp + ) return to_float(value, target) if to_type.is_integer: # float -> int return self._emit_fp_to_int_saturating(value, to_type) @@ -353,7 +355,9 @@ def _emit_fp_to_int_saturating(self, value: ir.Value, to_type) -> ir.Value: 0, rather than producing a poison value""" base = "llvm.fptosi.sat" if to_type.is_signed else "llvm.fptoui.sat" result_type = to_type.llvm_type - fn = self.builder.module.declare_intrinsic(base, (result_type, value.type), ir.FunctionType(result_type, [value.type])) + fn = self.builder.module.declare_intrinsic( + base, (result_type, value.type), ir.FunctionType(result_type, [value.type]) + ) return self.builder.call(fn, [value]) @@ -379,8 +383,7 @@ def visit_AstDef(self, node: AstDef, state: CompileState): class EmitLlvmStmt(Emitter): - """Lowers Fpy statements into LLVM IR via a shared builder. - """ + """Lowers Fpy statements into LLVM IR via a shared builder.""" def __init__(self, builder: ir.IRBuilder): super().__init__() @@ -391,8 +394,7 @@ def emit(self, node, state: CompileState) -> None: emitter = self.emitters.get(type(node)) if emitter is None: raise BackendError( - f"LLVM backend doesn't handle statement " - f"{type(node).__name__} yet" + f"LLVM backend doesn't handle statement " f"{type(node).__name__} yet" ) return emitter(node, state) @@ -604,9 +606,7 @@ def _wasm_ld_version_str() -> str: is independent of the LLVM that compiled the IR. Returns "unavailable" if wasm-ld can't be run rather than failing -- this is only for --version.""" try: - result = subprocess.run( - _wasm_ld_command() + ["--version"], capture_output=True - ) + result = subprocess.run(_wasm_ld_command() + ["--version"], capture_output=True) except (BackendError, OSError): return "unavailable" if result.returncode != 0: @@ -659,9 +659,7 @@ def _link_wasm_object(obj: bytes) -> bytes: def _run_wasm_ld(args: list[str], stdin: bytes | None = None) -> bytes: """Run wasm-ld with *args*; return its stdout. Raises on link failure.""" - result = subprocess.run( - _wasm_ld_command() + args, input=stdin, capture_output=True - ) + result = subprocess.run(_wasm_ld_command() + args, input=stdin, capture_output=True) if result.returncode != 0: raise BackendError( f"wasm-ld failed to link the sequence:\n{result.stderr.decode()}" diff --git a/src/fpy/compiler.py b/src/fpy/compiler.py index 7e96ece..1643577 100644 --- a/src/fpy/compiler.py +++ b/src/fpy/compiler.py @@ -148,9 +148,7 @@ def text_to_ast(text: str): elif isinstance(e.orig_exc, fpy.error.SyntaxErrorDuringTransform): raise fpy.error.CompileError(e.orig_exc.msg, e.orig_exc.node) else: - raise fpy.error.CompileError( - f"Internal error during parsing: {e.orig_exc}" - ) + raise fpy.error.CompileError(f"Internal error during parsing: {e.orig_exc}") return transformed @@ -295,8 +293,7 @@ def analysis_to_fpybc_directives( def analysis_to_llvm_module( - body: AstBlock, - state: CompileState + body: AstBlock, state: CompileState ) -> tuple[ir.Module, list[FpyType]]: """Runs LLVM codegen passes on analysis results, returning an llvmlite ir.Module (the LLVM backend). @@ -338,10 +335,7 @@ def analysis_to_wat( return llvm_module_to_wasm_text(module), seq_arg_types -def ast_to_dependencies( - body: AstBlock, - state: CompileState -) -> list[str]: +def ast_to_dependencies(body: AstBlock, state: CompileState) -> list[str]: """Return the list of .bin paths that a sequence source file depends on. Runs only the passes needed to resolve command symbols — does not attempt @@ -380,5 +374,7 @@ def ast_to_dependencies( raise state.errors[0] if state.ground_binary_dir is not None: - return [str(Path(state.ground_binary_dir) / name) for name in discover.bin_names] + return [ + str(Path(state.ground_binary_dir) / name) for name in discover.bin_names + ] return discover.bin_names diff --git a/src/fpy/desugaring.py b/src/fpy/desugaring.py index a735581..1a8bec3 100644 --- a/src/fpy/desugaring.py +++ b/src/fpy/desugaring.py @@ -341,10 +341,10 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): class DesugarCheckStatements(Transformer): """ Desugars check statements into while loops BEFORE semantic analysis. - + This runs before AssignIds, so we don't need to worry about node IDs. The generated AST nodes will go through normal semantic analysis. - + A check statement: check [timeout ] [persist ] [period ]: @@ -386,112 +386,117 @@ class DesugarCheckStatements(Transformer): else: """ - + def __init__(self): super().__init__() self.var_counter = 0 self.meta = None # Will be set when desugaring each check statement - + def new_var_name(self) -> str: """Generate a unique anonymous variable name.""" name = f"$check{self.var_counter}" self.var_counter += 1 return name - + def ident(self, name: str) -> AstIdent: return AstIdent(self.meta, name) - + def number(self, val: int) -> AstNumber: return AstNumber(self.meta, val) - + def boolean(self, val: bool) -> AstBoolean: return AstBoolean(self.meta, val) - + def member(self, parent, attr: str) -> AstGetAttr: return AstGetAttr(self.meta, parent, attr) - + def qualified_name(self, *parts: str): - """Create a qualified name reference from parts (e.g., 'Fw', 'Time' -> Fw.Time). - """ + """Create a qualified name reference from parts (e.g., 'Fw', 'Time' -> Fw.Time).""" if len(parts) == 0: raise ValueError("qualified_name requires at least one part") - + result = self.ident(parts[0]) for part in parts[1:]: result = self.member(result, part) return result - + def call(self, func_name: str, *args) -> AstFuncCall: func = self.ident(func_name) return AstFuncCall(self.meta, func, list(args) if args else []) - + def call_parts(self, func_parts: list[str], *args) -> AstFuncCall: func = self.qualified_name(*func_parts) return AstFuncCall(self.meta, func, list(args) if args else []) - + def call_expr(self, func_expr, *args) -> AstFuncCall: return AstFuncCall(self.meta, func_expr, list(args) if args else []) - + def binary(self, lhs, op: str, rhs) -> AstBinaryOp: return AstBinaryOp(self.meta, lhs, op, rhs) - + def unary(self, op: str, val) -> AstUnaryOp: return AstUnaryOp(self.meta, op, val) - + def assign(self, lhs, rhs, type_ann=None) -> AstAssign: return AstAssign(self.meta, lhs, type_ann, rhs) - + def if_stmt(self, cond, body_stmts, else_stmts=None) -> AstIf: body = AstBlock(self.meta, list(body_stmts)) els = AstBlock(self.meta, list(else_stmts)) if else_stmts else None return AstIf(self.meta, cond, body, [], els) - + def while_stmt(self, cond, body_stmts) -> AstWhile: body = AstBlock(self.meta, list(body_stmts)) return AstWhile(self.meta, cond, body) - + def break_stmt(self) -> AstBreak: return AstBreak(self.meta) - + def visit_AstCheck(self, node: AstCheck, state: CompileState) -> list[Ast]: """ Desugar a single check statement into a list of statements. """ # Use the check node's meta for all generated nodes (for error reporting) self.meta = node.meta - + # Generate unique variable names for this check statement check_state_name = self.new_var_name() current_time_name = self.new_var_name() timed_out_name = self.new_var_name() succeeded_name = self.new_var_name() - + # Check if timeout is specified (None means no timeout) has_timeout = node.timeout is not None - + # Helper to reference check_state members def cs(attr: str): return self.member(self.ident(check_state_name), attr) - + # Build the CheckState constructor call - # $CheckState(persist=, timeout=, period=, + # $CheckState(persist=, timeout=, period=, # result=False, last_was_true=False, last_time_true=Fw.Time(0,0,0,0), time_started=now()) - + # Handle default values: # - persist: default to Fw.TimeIntervalValue(0, 0) (0 second interval) # - period: default to Fw.TimeIntervalValue(1, 0) (1 second interval) # - timeout: if not specified, use a dummy value (but we skip timeout check logic) - + persist_expr = ( - copy.deepcopy(node.persist) if node.persist is not None - else self.call_parts(["Fw", "TimeIntervalValue"], self.number(0), self.number(0)) + copy.deepcopy(node.persist) + if node.persist is not None + else self.call_parts( + ["Fw", "TimeIntervalValue"], self.number(0), self.number(0) + ) ) - + period_expr = ( - copy.deepcopy(node.period) if node.period is not None - else self.call_parts(["Fw", "TimeIntervalValue"], self.number(1), self.number(0)) + copy.deepcopy(node.period) + if node.period is not None + else self.call_parts( + ["Fw", "TimeIntervalValue"], self.number(1), self.number(0) + ) ) - + # The user supplies the timeout as a relative Fw.TimeIntervalValue. # We turn it into an absolute deadline once, here at entry, as # time_add(now(), ). The deadline inherits now()'s timebase, @@ -505,65 +510,79 @@ def cs(attr: str): # Dummy timeout value - won't be used since we skip timeout checks timeout_expr_to_use = self.call_parts( ["Fw", "TimeValue"], - self.qualified_name("TimeBase", "TB_NONE"), self.number(0), self.number(0), self.number(0) + self.qualified_name("TimeBase", "TB_NONE"), + self.number(0), + self.number(0), + self.number(0), ) - + check_state_init = self.call_expr( - self.qualified_name("$CheckState"), - persist_expr, # persist - timeout_expr_to_use, # timeout (absolute time) - period_expr, # period - self.boolean(False), # result - self.boolean(False), # last_was_true - self.call_parts( # last_time_true = Fw.TimeValue(TimeBase.TB_NONE,0,0,0) + self.qualified_name("$CheckState"), + persist_expr, # persist + timeout_expr_to_use, # timeout (absolute time) + period_expr, # period + self.boolean(False), # result + self.boolean(False), # last_was_true + self.call_parts( # last_time_true = Fw.TimeValue(TimeBase.TB_NONE,0,0,0) ["Fw", "TimeValue"], - self.qualified_name("TimeBase", "TB_NONE"), self.number(0), self.number(0), self.number(0) + self.qualified_name("TimeBase", "TB_NONE"), + self.number(0), + self.number(0), + self.number(0), ), - self.call("now"), # time_started + self.call("now"), # time_started ) - + # 1. $check_state: $CheckState = $CheckState(...) init_check_state = self.assign( self.ident(check_state_name), check_state_init, - self.qualified_name("$CheckState") + self.qualified_name("$CheckState"), ) - + # Build the while loop body # 2. $current_time: Fw.TimeValue = now() get_current_time = self.assign( self.ident(current_time_name), self.call("now"), - self.qualified_name("Fw", "TimeValue") + self.qualified_name("Fw", "TimeValue"), ) - + # Build the while loop body statements while_body_stmts = [get_current_time] - + # Only add timeout check if timeout is specified if has_timeout: # 3. $timed_out: Fw.TimeComparison = time_cmp($current_time, $check_state.timeout) check_timeout = self.assign( self.ident(timed_out_name), self.call("time_cmp", self.ident(current_time_name), cs("timeout")), - self.qualified_name("Fw", "TimeComparison") + self.qualified_name("Fw", "TimeComparison"), ) - + # 4. assert $timed_out != Fw.TimeComparison.INCOMPARABLE, 1 assert_comparable = AstAssert( self.meta, - self.binary(self.ident(timed_out_name), "!=", self.qualified_name("Fw", "TimeComparison", "INCOMPARABLE")), - self.number(1) + self.binary( + self.ident(timed_out_name), + "!=", + self.qualified_name("Fw", "TimeComparison", "INCOMPARABLE"), + ), + self.number(1), ) - + # 5. if $timed_out == Fw.TimeComparison.GT: break timeout_break = self.if_stmt( - self.binary(self.ident(timed_out_name), "==", self.qualified_name("Fw", "TimeComparison", "GT")), - [self.break_stmt()] + self.binary( + self.ident(timed_out_name), + "==", + self.qualified_name("Fw", "TimeComparison", "GT"), + ), + [self.break_stmt()], ) - + while_body_stmts.extend([check_timeout, assert_comparable, timeout_break]) - + # 6. Build the condition check block # if : # if not $check_state.last_was_true: @@ -575,64 +594,70 @@ def cs(attr: str): # break # else: # $check_state.last_was_true = False - + # Inner if: not last_was_true update_last_true = self.if_stmt( self.unary("not", cs("last_was_true")), [ self.assign(cs("last_time_true"), self.ident(current_time_name)), self.assign(cs("last_was_true"), self.boolean(True)), - ] + ], ) - + # Check if condition has persisted long enough check_persist = self.assign( self.ident(succeeded_name), self.call( "time_interval_cmp", - self.call("time_sub", self.ident(current_time_name), cs("last_time_true")), - cs("persist") + self.call( + "time_sub", self.ident(current_time_name), cs("last_time_true") + ), + cs("persist"), ), - self.qualified_name("Fw", "TimeComparison") + self.qualified_name("Fw", "TimeComparison"), ) - + # if succeeded == Fw.TimeComparison.EQ or succeeded == Fw.TimeComparison.GT success_check = self.if_stmt( self.binary( - self.binary(self.ident(succeeded_name), "==", self.qualified_name("Fw", "TimeComparison", "GT")), + self.binary( + self.ident(succeeded_name), + "==", + self.qualified_name("Fw", "TimeComparison", "GT"), + ), "or", - self.binary(self.ident(succeeded_name), "==", self.qualified_name("Fw", "TimeComparison", "EQ")) + self.binary( + self.ident(succeeded_name), + "==", + self.qualified_name("Fw", "TimeComparison", "EQ"), + ), ), [ self.assign(cs("result"), self.boolean(True)), self.break_stmt(), - ] + ], ) - - + # Main condition check if/else condition_check = self.if_stmt( copy.deepcopy(node.condition), [update_last_true, check_persist, success_check], - [self.assign(cs("last_was_true"), self.boolean(False))] + [self.assign(cs("last_was_true"), self.boolean(False))], ) - + # 7. sleep($check_state.period.seconds, $check_state.period.useconds) sleep_call = self.call( "sleep", self.member(cs("period"), "seconds"), - self.member(cs("period"), "useconds") + self.member(cs("period"), "useconds"), ) - + # Add condition check and sleep to while body while_body_stmts.extend([condition_check, sleep_call]) - + # Build the while loop - while_loop = self.while_stmt( - self.boolean(True), - while_body_stmts - ) - + while_loop = self.while_stmt(self.boolean(True), while_body_stmts) + # 8. Final if/else to run body or timeout_body # if $check_state.result: # @@ -643,34 +668,33 @@ def cs(attr: str): # - No body, no timeout_body: just the while loop (wait until condition) # - No body, has timeout_body: if not $check_state.result: result = [init_check_state, while_loop] - + if node.body is not None: final_if = AstIf( self.meta, cs("result"), - node.body, # Use original body - [], # No elifs - node.timeout_body # Use original timeout_body (may be None) + node.body, # Use original body + [], # No elifs + node.timeout_body, # Use original timeout_body (may be None) ) result.append(final_if) elif node.timeout_body is not None: final_if = AstIf( self.meta, self.unary("not", cs("result")), - node.timeout_body, # Run timeout_body when check failed - [], # No elifs - None # No else + node.timeout_body, # Run timeout_body when check failed + [], # No elifs + None, # No else ) result.append(final_if) - - return result + return result class DesugarTimeOperators(Transformer): """ Desugar binary operators on Fw.Time and Fw.TimeIntervalValue types into function calls. - + This pass transforms: - Time - Time -> time_sub(lhs, rhs) - Time + TimeInterval -> time_add(lhs, rhs) @@ -700,18 +724,26 @@ def new( state.resolved_symbols[node] = resolved_symbol return node - def _update_field_access_refs(self, old_node: Ast, new_node: Ast, state: CompileState): + def _update_field_access_refs( + self, old_node: Ast, new_node: Ast, state: CompileState + ): """Update any FieldAccess symbols that reference old_node to point to new_node.""" for sym in state.resolved_symbols.values(): if isinstance(sym, FieldAccess) and sym.parent_expr is old_node: sym.parent_expr = new_node def _make_func_call( - self, node: AstBinaryOp, func_name: str, result_type: FpyType, state: CompileState + self, + node: AstBinaryOp, + func_name: str, + result_type: FpyType, + state: CompileState, ) -> AstFuncCall: """Create a function call AST node with proper state.""" func_symbol = state.global_callable_scope.get(func_name) - assert func_symbol is not None, f"Function {func_name} not found in callable scope" + assert ( + func_symbol is not None + ), f"Function {func_name} not found in callable scope" func_node = self.new( state, @@ -738,7 +770,7 @@ def _make_cmp_expr( ) -> AstBinaryOp: """ Create a comparison expression using a cmp function. - + For < : cmp(lhs, rhs) == -1 For > : cmp(lhs, rhs) == 1 For <= : cmp(lhs, rhs) != 1 @@ -751,7 +783,7 @@ def _make_cmp_expr( # subsequent integer comparison. cmp_call = self._make_func_call(node, cmp_func, TIME_COMPARISON.rep_type, state) state.contextual_types[cmp_call] = I64 - + op = node.op if op == BinaryStackOp.LESS_THAN: cmp_val = -1 @@ -798,11 +830,11 @@ def _make_cmp_expr( def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): lhs_type = state.contextual_types.get(node.lhs) rhs_type = state.contextual_types.get(node.rhs) - + time_op = TIME_OPS.get((lhs_type, rhs_type, node.op)) if time_op is None: return None - + _, result_type, func_name, is_comparison = time_op if is_comparison: return self._make_cmp_expr(node, func_name, state) diff --git a/src/fpy/dictionary.py b/src/fpy/dictionary.py index 407e4b5..d89b194 100644 --- a/src/fpy/dictionary.py +++ b/src/fpy/dictionary.py @@ -24,7 +24,6 @@ BOOL, ) - # Map of representation type name -> expected by enum rep_type ENUM_REP_TYPES = {"U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64"} diff --git a/src/fpy/error.py b/src/fpy/error.py index 47cf006..c80bd43 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -68,6 +68,7 @@ def dim(cls, s: str) -> str: class SyntaxErrorDuringTransform(Exception): """Raised during AST transformation for user-facing syntax errors.""" + def __init__(self, msg: str, node=None): self.msg = msg self.node = node @@ -97,9 +98,7 @@ def format_diagnostic(msg: str, node, stack_trace: str = "", color=Colors.red) - source_end_line = min(len(input_lines), source_end_line) # this is the list of all the src lines we will display - source_to_display: list[str] = input_lines[ - source_start_line : source_end_line + 1 - ] + source_to_display: list[str] = input_lines[source_start_line : source_end_line + 1] # reserve this much space for the line numbers # add two extra spaces for the caret to display multiline errors on lhs @@ -134,7 +133,9 @@ def format_diagnostic(msg: str, node, stack_trace: str = "", color=Colors.red) - error_highlight = " " * (meta.column - 1 + line_number_space + 3) + color(caret_str) source_to_display.insert(node_start_line_in_ctx + 1, error_highlight) location = f"{file_name_str}:{meta.line}" - result = f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(color(msg))}\n" + result = ( + f"{stack_trace_optional}{Colors.cyan(location)} {Colors.bold(color(msg))}\n" + ) result += "\n".join(source_to_display) return result @@ -247,6 +248,7 @@ def __str__(self) -> str: def handle_lark_error(err): import sys + assert isinstance(err, LarkError), err if isinstance(err, UnexpectedToken): print(str(CompileError("Invalid syntax", err.token)), file=sys.stderr) diff --git a/src/fpy/ir.py b/src/fpy/ir.py index 54fa32e..36867fc 100644 --- a/src/fpy/ir.py +++ b/src/fpy/ir.py @@ -35,4 +35,5 @@ class IrIf(Ir): @dataclass(frozen=True, unsafe_hash=True) class IrPushLabelOffset(Ir): """pushes the label's offset from the start of the file to the stack, as a StackSizeType""" - label: IrLabel \ No newline at end of file + + label: IrLabel diff --git a/src/fpy/macros.py b/src/fpy/macros.py index 777784c..3537f06 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -219,7 +219,11 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive generate_llvm=generate_exit_llvm, ), "ln": BuiltinFuncSymbol( - "ln", F64, [("operand", F64, None)], lambda n, c: [FloatLogDirective()], generate_log_llvm + "ln", + F64, + [("operand", F64, None)], + lambda n, c: [FloatLogDirective()], + generate_log_llvm, ), "now": BuiltinFuncSymbol("now", TIME, [], lambda n, c: [PushTimeDirective()]), "rand": BuiltinFuncSymbol("rand", U32, [], lambda n, c: [PushRandDirective()]), diff --git a/src/fpy/main.py b/src/fpy/main.py index 12fefbb..ce088c1 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -62,9 +62,7 @@ def get_version_str() -> str: def compile_main(args: list[str] = None): compiler_version = f"{get_version_str()}\nbackend:\n{backend_version_str()}" - arg_parser = argparse.ArgumentParser( - description=f"Fpy compiler {compiler_version}" - ) + arg_parser = argparse.ArgumentParser(description=f"Fpy compiler {compiler_version}") arg_parser.add_argument( "--version", action="version", version=f"%(prog)s {compiler_version}" ) @@ -198,10 +196,7 @@ def compile_main(args: list[str] = None): # semantics try: - state = analyze_ast( - body, - state - ) + state = analyze_ast(body, state) except RecursionError: print("Recursion limit exceeded in semantics passes", file=sys.stderr) sys.exit(1) @@ -257,7 +252,9 @@ def compile_main(args: list[str] = None): arg_specs = [(name, t.name, t.max_size) for name, t in seq_arg_types] output_bytes, crc = serialize_directives(output, arg_specs) output_path.write_bytes(output_bytes) - print(f"{output_path}\nCRC {hex(crc)} size {human_readable_size(len(output_bytes))}") + print( + f"{output_path}\nCRC {hex(crc)} size {human_readable_size(len(output_bytes))}" + ) else: assert False, parsed_args.emit diff --git a/src/fpy/model.py b/src/fpy/model.py index 323226f..343d490 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -147,8 +147,12 @@ class FpySequencerModel: CMD_RESPONSE_EXECUTION_ERROR = 4 def __init__( - self, stack_size=4096, cmd_dict: dict[int, CmdDef] = None, - time_base: int = 0, time_context: int = 0, initial_time_us: int = 0, + self, + stack_size=4096, + cmd_dict: dict[int, CmdDef] = None, + time_base: int = 0, + time_context: int = 0, + initial_time_us: int = 0, failing_opcodes: set[int] = None, seq_run_opcodes: set[int] = None, arg_type_defs: dict[str, FpyType] = None, @@ -595,7 +599,9 @@ def _handle_seq_run(self, opcode: int, args: bytes): # Deserialize SeqArgs: { $size: U64, buffer: [N] U8 } seq_args_type = cmd.arguments[2][2] # $size is FwSizeType (U64) - size_val, offset = FpyValue.deserialize(seq_args_type.members[0].type, args, offset) + size_val, offset = FpyValue.deserialize( + seq_args_type.members[0].type, args, offset + ) actual_size = size_val.val # Extract actual arg bytes from the buffer child_args = bytes(args[offset : offset + actual_size]) @@ -611,7 +617,9 @@ def _handle_seq_run(self, opcode: int, args: bytes): # Resolve child arg types child_arg_types = [] if child_arg_specs: - child_arg_types = [t for _, t in resolve_arg_specs(child_arg_specs, self.arg_type_defs)] + child_arg_types = [ + t for _, t in resolve_arg_specs(child_arg_specs, self.arg_type_defs) + ] # Create and run a child model child_model = FpySequencerModel( @@ -1096,10 +1104,10 @@ def handle_fdiv(self, dir: FloatDivideDirective): # IEEE 754: division by zero produces inf, -inf, or nan. # The sign of the result depends on the signs of BOTH operands. if lhs == 0.0: - self.push(float('nan')) + self.push(float("nan")) else: result_sign = math.copysign(1.0, lhs) * math.copysign(1.0, rhs) - self.push(math.copysign(float('inf'), result_sign)) + self.push(math.copysign(float("inf"), result_sign)) return None self.push(lhs / rhs) return None @@ -1153,11 +1161,11 @@ def handle_fpow(self, dir: FloatExponentDirective): return None except (ValueError, OverflowError): # C++ pow() returns NaN for domain errors - self.push(float('nan')) + self.push(float("nan")) return None # Python can return complex for e.g. (-1.0)**0.5; C++ pow() returns NaN if isinstance(result, complex): - self.push(float('nan')) + self.push(float("nan")) return None self.push(result) return None @@ -1236,12 +1244,15 @@ def handle_push_time(self, dir: PushTimeDirective): # Convert simulated time to seconds and microseconds seconds = self.simulated_time_us // 1000000 useconds = self.simulated_time_us % 1000000 - time_val = FpyValue(TIME, { - "timeBase": self.time_base, - "timeContext": self.time_context, - "seconds": seconds, - "useconds": useconds, - }) + time_val = FpyValue( + TIME, + { + "timeBase": self.time_base, + "timeContext": self.time_context, + "seconds": seconds, + "useconds": useconds, + }, + ) self.push(time_val.serialize()) return None @@ -1273,7 +1284,10 @@ def handle_call(self, dir: CallDirective): return DirectiveErrorCode.STACK_UNDERFLOW # Check for stack overflow before pushing header (8 bytes net: pop 4, push 8) - if len(self.stack) + STACK_FRAME_HEADER_SIZE - StackSizeType.max_size > self.max_stack_size: + if ( + len(self.stack) + STACK_FRAME_HEADER_SIZE - StackSizeType.max_size + > self.max_stack_size + ): return DirectiveErrorCode.STACK_OVERFLOW offset = self.pop(size=StackSizeType.max_size, signed=False) @@ -1330,7 +1344,9 @@ def handle_pop_event(self, dir: PopEventDirective): message_size = self.pop(type=int, signed=False, size=StackSizeType.max_size) if len(self.stack) < message_size + 1: return DirectiveErrorCode.STACK_UNDERFLOW - message = bytes(self.pop(type=bytes, size=message_size)) if message_size > 0 else b"" + message = ( + bytes(self.pop(type=bytes, size=message_size)) if message_size > 0 else b"" + ) severity = self.pop(type=int, signed=False, size=1) severity_names = {v: k for k, v in LOG_SEVERITY.enum_dict.items()} severity_name = severity_names.get(severity, f"UNKNOWN({severity})") diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 2a37ec7..4b24b95 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -456,7 +456,7 @@ class ResolveQualifiedIdentifiers(TopDownVisitor): def may_contain_sub_definitions(self, sym: Symbol) -> bool: """return True if a symbol definition may contain other definitions. The only definitions in Fpy which may contain other definitions are - modules (whose only purpose is to contain other definitions) and + modules (whose only purpose is to contain other definitions) and enum types (who contain definitions of enum consts)""" return is_instance_compat(sym, ModuleSymbol) or ( is_instance_compat(sym, FpyType) and sym.kind == TypeKind.ENUM @@ -740,6 +740,7 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): if not self.check_resolved(node.func, NameGroup.CALLABLE, state): return + class CheckForConstantSizeTypes(Visitor): def visit_AstDef(self, node: AstDef, state: CompileState): @@ -764,7 +765,6 @@ def visit_AstDef(self, node: AstDef, state: CompileState): ) return - def visit_AstAssign(self, node: AstAssign, state: CompileState): if node.type_ann is None: return @@ -791,6 +791,7 @@ def visit_AstSequenceMetadata(self, node: AstSequenceMetadata, state: CompileSta ) return + class UpdateStateWithTypes(Visitor): def visit_AstDef(self, node: AstDef, state: CompileState): @@ -1034,7 +1035,8 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): if not is_instance_compat(sym, FunctionSymbol): return missing = [ - g for g in state.function_global_uses[sym.definition] + g + for g in state.function_global_uses[sym.definition] if g not in self.defined ] if not missing: diff --git a/src/fpy/state.py b/src/fpy/state.py index f1597ef..aa0be9c 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -7,7 +7,17 @@ from fpy.error import CompileError, CompileWarning, DictionaryError, WarningType from fpy.ir import Ir, IrLabel from fpy.macros import MACROS -from fpy.symbols import CallableSymbol, CastSymbol, CommandSymbol, Symbol, SymbolTable, TypeCtorSymbol, VariableSymbol, create_symbol_table, merge_symbol_tables +from fpy.symbols import ( + CallableSymbol, + CastSymbol, + CommandSymbol, + Symbol, + SymbolTable, + TypeCtorSymbol, + VariableSymbol, + create_symbol_table, + merge_symbol_tables, +) from fpy.syntax import ( Ast, AstBreak, @@ -51,6 +61,7 @@ class ForLoopAnalysis: loop_var: VariableSymbol upper_bound_var: VariableSymbol + @dataclass class CompileState: """a collection of input, internal and output state variables and maps""" @@ -115,7 +126,9 @@ class CompileState: in positional order. Default values are filled in for arguments not provided at the call site (or struct members / array elements with defaults).""" - function_global_uses: dict[AstDef, list[VariableSymbol]] = field(default_factory=dict) + function_global_uses: dict[AstDef, list[VariableSymbol]] = field( + default_factory=dict + ) """function definition -> globals it reads. Populated with direct uses by CollectFunctionGlobalUses, then grown to the transitive closure (globals read through called functions too) by ResolveTransitiveGlobalUses.""" diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py index ac497b9..25e4ba1 100644 --- a/src/fpy/symbols.py +++ b/src/fpy/symbols.py @@ -111,7 +111,6 @@ class VariableSymbol: locals, a GlobalVariable for globals). Set during codegen.""" - next_symbol_table_id = 0 @@ -122,7 +121,7 @@ class NameGroup(str, Enum): class SymbolTable(dict): - def __init__(self, parent = None): + def __init__(self, parent=None): global next_symbol_table_id super().__init__() self.id = next_symbol_table_id diff --git a/src/fpy/syntax.py b/src/fpy/syntax.py index 4a28d9d..72a3654 100644 --- a/src/fpy/syntax.py +++ b/src/fpy/syntax.py @@ -244,7 +244,9 @@ class AstAnonArray(Ast): AstOp = Union[AstBinaryOp, AstUnaryOp] AstReference = Union[AstGetAttr, AstIndexExpr, AstIdent] -AstExpr = Union[AstFuncCall, AstLiteral, AstReference, AstOp, AstRange, AstAnonStruct, AstAnonArray] +AstExpr = Union[ + AstFuncCall, AstLiteral, AstReference, AstOp, AstRange, AstAnonStruct, AstAnonArray +] @dataclass @@ -286,7 +288,7 @@ class AstCheck(Ast): condition: AstExpr timeout: Union[AstExpr, None] # Default: no timeout persist: Union[AstExpr, None] # Default: 0 second interval - period: Union[AstExpr, None] # Default: 1 second interval + period: Union[AstExpr, None] # Default: 1 second interval body: Union["AstBlock", None] # None for body-less check timeout_body: Union["AstBlock", None] = None @@ -321,6 +323,7 @@ class AstDef(Ast): return_type: Union[AstExpr, None] body: AstBlock + @dataclass class AstSequenceMetadata(Ast): parameters: Union[list[tuple[AstIdent, AstExpr]], None] @@ -340,10 +343,19 @@ class AstSequenceMetadata(Ast): AstAssert, AstDef, AstSequenceMetadata, - AstReturn + AstReturn, ] AstStmtWithExpr = Union[ - AstExpr, AstAssign, AstIf, AstElif, AstFor, AstWhile, AstCheck, AstAssert, AstDef, AstReturn + AstExpr, + AstAssign, + AstIf, + AstElif, + AstFor, + AstWhile, + AstCheck, + AstAssert, + AstDef, + AstReturn, ] AstNodeWithSideEffects = Union[ AstFuncCall, @@ -357,7 +369,7 @@ class AstSequenceMetadata(Ast): AstBreak, AstContinue, AstDef, - AstReturn + AstReturn, ] @@ -410,21 +422,23 @@ def handle_str(meta, s: str): # which optional clauses were provided, regardless of how many are present. def handle_check_clause(tag): """Create a handler that tags an expression with the given clause name.""" + @v_args(meta=True, inline=True) def wrapper(self, meta, expr): return (tag, expr) + return wrapper def handle_check_clauses(meta, children): """Parse multi-line check clauses and body statements. - + Returns a tuple of (clause_list, body_stmts) where clause_list is a list of (clause_type, expr) tuples and body_stmts is an AstBlock or None (body-less). """ clauses = [] stmts = [] - + for child in children: if isinstance(child, tuple) and len(child) == 2: # This is a clause: (clause_type, expr) @@ -432,7 +446,7 @@ def handle_check_clauses(meta, children): else: # This is a statement AST node stmts.append(child) - + # Return as a special tuple that handle_check_stmt can recognize body = AstBlock(meta, stmts) if stmts else None return ("check_clauses_result", clauses, body) @@ -441,7 +455,7 @@ def handle_check_clauses(meta, children): def handle_check_stmt(meta, children): """Parse check statement with optional timeout/persist/period clauses.""" from fpy.error import SyntaxErrorDuringTransform - + condition = children[0] timeout = None persist = None @@ -449,25 +463,35 @@ def handle_check_stmt(meta, children): body = None timeout_body = None body_set = False # distinguish "body not yet assigned" from "body intentionally None (body-less)" - + def set_clause(clause_type, expr): nonlocal timeout, persist, period if clause_type == "timeout": if timeout is not None: - raise SyntaxErrorDuringTransform(f"Duplicate 'timeout' clause in check statement", expr) + raise SyntaxErrorDuringTransform( + f"Duplicate 'timeout' clause in check statement", expr + ) timeout = expr elif clause_type == "persist": if persist is not None: - raise SyntaxErrorDuringTransform(f"Duplicate 'persist' clause in check statement", expr) + raise SyntaxErrorDuringTransform( + f"Duplicate 'persist' clause in check statement", expr + ) persist = expr elif clause_type == "period": if period is not None: - raise SyntaxErrorDuringTransform(f"Duplicate 'period' clause in check statement", expr) + raise SyntaxErrorDuringTransform( + f"Duplicate 'period' clause in check statement", expr + ) period = expr - + for child in children[1:]: # Handle check_clauses which returns ("check_clauses_result", clauses, body) - if isinstance(child, tuple) and len(child) == 3 and child[0] == "check_clauses_result": + if ( + isinstance(child, tuple) + and len(child) == 3 + and child[0] == "check_clauses_result" + ): _, clauses, stmts = child for clause_type, expr in clauses: set_clause(clause_type, expr) @@ -482,7 +506,7 @@ def set_clause(clause_type, expr): body_set = True else: timeout_body = child - + return AstCheck(meta, condition, timeout, persist, period, body, timeout_body) @@ -493,6 +517,7 @@ def handle_parameter(meta, args): default_value = args[2] if len(args) == 3 else None return (name, type_expr, default_value) + def handle_sequence_argument(meta, args): """Parse a sequence argument: (name, type)""" assert len(args) == 2, f"Expected 2 args, got {len(args)}: {args}" @@ -571,7 +596,7 @@ def positional_argument(self, meta, value): sequence_stmt_parameters = no_inline_or_meta(list) sequence_stmt_parameter = no_inline(handle_sequence_argument) - NAME = lambda self, token: token[1:] if token.startswith('$') else token + NAME = lambda self, token: token[1:] if token.startswith("$") else token DEC_NUMBER = int FLOAT_NUMBER = Decimal HEX_NUMBER = lambda self, token: int(token, 16) diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 76ac122..5afebb5 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -110,11 +110,13 @@ def run_seq_wasm( on-board target runtime) via the runner harness built by conftest.""" import subprocess - assert SPACEWASM_RUNNER is not None, ( - "SPACEWASM_RUNNER not set; run pytest with --wasm" - ) + assert ( + SPACEWASM_RUNNER is not None + ), "SPACEWASM_RUNNER not set; run pytest with --wasm" - wasm = compile_seq_wasm(seq, ground_binary_dir, import_search_dirs=import_search_dirs) + wasm = compile_seq_wasm( + seq, ground_binary_dir, import_search_dirs=import_search_dirs + ) wasm_file = tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) wasm_file.write(wasm) wasm_file.close() @@ -228,9 +230,7 @@ def run_seq( old_cwd = os.getcwd() os.chdir(ground_binary_dir) try: - error_code, trap = model.run( - directives, tlm_db, args=args, arg_types=arg_types - ) + error_code, trap = model.run(directives, tlm_db, args=args, arg_types=arg_types) finally: if old_cwd is not None: os.chdir(old_cwd) @@ -263,7 +263,9 @@ def run_seq( raise RuntimeError(f"Sequence leaked {len(model.stack) - expected_stack} bytes") -def assert_compile_success(fprime_test_api, seq: str, import_search_dirs: list[str] | None = None): +def assert_compile_success( + fprime_test_api, seq: str, import_search_dirs: list[str] | None = None +): if USE_WASM: compile_seq_wasm(seq, import_search_dirs=import_search_dirs) return @@ -286,7 +288,9 @@ def assert_run_success( ): if USE_WASM: code = run_seq_wasm( - seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs + seq, + ground_binary_dir=ground_binary_dir, + import_search_dirs=import_search_dirs, ) if code != DirectiveErrorCode.NO_ERROR.value: raise RuntimeError(f"wasm sequence returned error code {code}") @@ -380,7 +384,9 @@ def assert_run_failure( # The wasm backend has no separate validation step or VM-internal # faults: a failed sequence is one whose entry point returns nonzero. code = run_seq_wasm( - seq, ground_binary_dir=ground_binary_dir, import_search_dirs=import_search_dirs + seq, + ground_binary_dir=ground_binary_dir, + import_search_dirs=import_search_dirs, ) if code == DirectiveErrorCode.NO_ERROR.value: raise RuntimeError("wasm sequence succeeded") @@ -445,6 +451,7 @@ def assert_run_failure( except RuntimeError as e: if validation_error: raise RuntimeError("Expected ValidationError, got", type(e).__name__, e) + # The failure surfaces as either a DirectiveErrorCode trap or a raw exit # code int; the expected value may likewise be either. Compare by integer # value so e.g. an exit code of 7 matches DirectiveErrorCode.EXIT_WITH_ERROR. diff --git a/src/fpy/types.py b/src/fpy/types.py index 67659cd..1be18bc 100644 --- a/src/fpy/types.py +++ b/src/fpy/types.py @@ -381,7 +381,7 @@ def value_range(self) -> tuple[int | float, int | float]: F64 = FpyType(TypeKind.F64, "F64") BOOL = FpyType(TypeKind.BOOL, "bool") -# distinct singleton so that the in-place update +# distinct singleton so that the in-place update # is visible everywhere the object is referenced. FwSizeStoreType = FpyType(TypeKind.U16, "U16") @@ -576,9 +576,7 @@ def deserialize(typ: FpyType, data: bytes, offset: int = 0) -> tuple[FpyValue, i return FpyValue(typ, raw), offset + size if kind in (TypeKind.STRING, TypeKind.INTERNAL_STRING): - size_val, offset = FpyValue.deserialize( - FwSizeStoreType, data, offset - ) + size_val, offset = FpyValue.deserialize(FwSizeStoreType, data, offset) str_len = size_val.val s = data[offset : offset + str_len].decode("utf-8") offset += str_len diff --git a/src/fpy/visitors.py b/src/fpy/visitors.py index cf014dd..9dc525c 100644 --- a/src/fpy/visitors.py +++ b/src/fpy/visitors.py @@ -13,7 +13,6 @@ from fpy.bytecode.directives import Directive from fpy.ir import Ir - # Cache for visitor method mappings, keyed by visitor class _visitor_cache: dict[type, dict[type, str]] = {} @@ -21,10 +20,13 @@ class _StopDescent: """Sentinel returned by a visit_* method to prevent the framework from descending into the visited node's children.""" + __slots__ = () + def __repr__(self): return "STOP_DESCENT" + STOP_DESCENT = _StopDescent() diff --git a/test/conftest.py b/test/conftest.py index c461496..303cf94 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -112,4 +112,4 @@ def configure_fpy_debug(request): def fprime_test_api_override(request): if request.config.getoption("--use-gds"): return request.getfixturevalue("fprime_test_api_session") - return None \ No newline at end of file + return None diff --git a/test/fpy/test_anon_exprs.py b/test/fpy/test_anon_exprs.py index d231a80..ba25b8c 100644 --- a/test/fpy/test_anon_exprs.py +++ b/test/fpy/test_anon_exprs.py @@ -3,10 +3,9 @@ assert_compile_failure, ) - - # ── Anonymous struct tests ────────────────────────────────────────────── + class TestAnonStructBasic: def test_anon_struct_all_members(self, fprime_test_api): """Anonymous struct with all members specified.""" @@ -111,6 +110,7 @@ def make_interval() -> Fw.TimeIntervalValue: # ── Anonymous array tests ────────────────────────────────────────────── + class TestAnonArrayBasic: def test_anon_array_simple(self, fprime_test_api): """Anonymous array with matching element count.""" @@ -208,6 +208,7 @@ def make_arr() -> Svc.ComQueueDepth: # ── Nested anonymous expressions ─────────────────────────────────────── + class TestAnonNested: def test_anon_array_of_anon_structs(self, fprime_test_api): """Array of anonymous structs: [{...}, {...}, ...]""" @@ -232,6 +233,7 @@ def test_anon_struct_containing_anon_array(self, fprime_test_api): # ── Direct member/index access on anonymous literals ──────────────────── + class TestAnonDirectAccess: def test_anon_struct_member_access(self, fprime_test_api): """Access a specific member from an anonymous struct literal.""" @@ -283,6 +285,7 @@ def test_anon_array_dynamic_index_fails(self, fprime_test_api): # ── Anonymous expressions in check statements ─────────────────────────── + class TestAnonExprInCheck: """Check statements accept Fw.TimeIntervalValue for persist/period/timeout. Verify that anonymous struct syntax works in each position.""" @@ -363,6 +366,7 @@ def test_check_anon_freq_partial(self, fprime_test_api): # ── Time arithmetic with anonymous structs ────────────────────────────── + class TestTimeArithmeticWithAnonStruct: """The + operator should work between Fw.Time and anonymous structs that are coercible to Fw.TimeIntervalValue.""" @@ -413,4 +417,4 @@ def test_anon_struct_plus_anon_struct_interval(self, fprime_test_api): assert interval.seconds == 3 assert interval.useconds == 0 """ - assert_run_success(fprime_test_api, seq) \ No newline at end of file + assert_run_success(fprime_test_api, seq) diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index 6a735ca..52af421 100644 --- a/test/fpy/test_arithmetic.py +++ b/test/fpy/test_arithmetic.py @@ -16,10 +16,10 @@ def test_overflow_compile_error(self, fprime_test_api): def test_const_fold_specific_float_binop(self, fprime_test_api): """Const folding binary ops on specific float types (F64, F32). - When both operands are cast to a specific float type (e.g. F64(1.5)), - their .val is a Python float, not Decimal. The const folder must handle - the bare `float` result from `float + float` etc. - """ + When both operands are cast to a specific float type (e.g. F64(1.5)), + their .val is a Python float, not Decimal. The const folder must handle + the bare `float` result from `float + float` etc. + """ seq = """ if F64(1.5) + F64(2.5) == 4.0: exit(0) @@ -74,6 +74,7 @@ def test_const_pow_decimal_overflow(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq, match="[Oo]verflow") + class TestBasicArithmetic: def test_add_unsigned(self, fprime_test_api): @@ -204,6 +205,7 @@ def test_order_of_operations(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestArithmeticWithBuiltins: def test_arithmetic_arg_to_builtin_bad_type(self, fprime_test_api): @@ -218,6 +220,7 @@ def test_arithmetic_arg_to_builtin(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestChainedOperations: def test_chain_mul(self, fprime_test_api): @@ -264,6 +267,7 @@ def test_chain_div(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestPowerModLog: def test_pow_unsigned(self, fprime_test_api): @@ -370,7 +374,8 @@ def test_pow_result_is_f64(self, fprime_test_api): ("F64", "U64", "9.0", "2", "F64", "4.0"), ], ) - def test_floor_divide_64_bit_numeric_types(self, + def test_floor_divide_64_bit_numeric_types( + self, fprime_test_api, lhs_type, rhs_type, @@ -395,7 +400,8 @@ def test_floor_divide_64_bit_numeric_types(self, ("U64", "I64", "9", "2", "U64", "4"), ], ) - def test_floor_divide_64_bit_bad_type_pairs(self, + def test_floor_divide_64_bit_bad_type_pairs( + self, fprime_test_api, lhs_type, rhs_type, @@ -432,20 +438,24 @@ def test_const_floor_divide_large_int_precision(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestUnaryOperators: - @pytest.mark.parametrize("type_name,value", [ - ("U8", "1"), - ("I8", "1"), - ("U16", "1"), - ("I16", "1"), - ("U32", "1"), - ("I32", "1"), - ("U64", "1"), - ("I64", "1"), - ("F32", "1.0"), - ("F64", "1.0"), - ]) + @pytest.mark.parametrize( + "type_name,value", + [ + ("U8", "1"), + ("I8", "1"), + ("U16", "1"), + ("I16", "1"), + ("U32", "1"), + ("I32", "1"), + ("U64", "1"), + ("I64", "1"), + ("F32", "1.0"), + ("F64", "1.0"), + ], + ) def test_unary_plus(self, fprime_test_api, type_name, value): seq = f""" var: {type_name} = {value} @@ -482,6 +492,7 @@ def test_negative_int_literal_unsigned_op(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestAbs: def test_abs_float(self, fprime_test_api): @@ -606,5 +617,3 @@ def test_int_floor_div_negative_divisor(self, fprime_test_api): assert result == -4 """ assert_run_success(fprime_test_api, seq) - - diff --git a/test/fpy/test_assembler.py b/test/fpy/test_assembler.py index b0603b6..669a836 100644 --- a/test/fpy/test_assembler.py +++ b/test/fpy/test_assembler.py @@ -454,21 +454,23 @@ def _test_roundtrip(self, original: Directive): dirs = [original] serialized, _ = serialize_directives(dirs) deserialized, arg_type_names = deserialize_directives(serialized) - + assert len(deserialized) == 1 assert arg_type_names == [] result = deserialized[0] - + # Check type matches assert type(result) == type(original) - + # Check all fields match for field in fields(original): - if field.name in ('meta', 'id'): + if field.name in ("meta", "id"): continue original_val = getattr(original, field.name) result_val = getattr(result, field.name) - assert original_val == result_val, f"Field {field.name}: {original_val} != {result_val}" + assert ( + original_val == result_val + ), f"Field {field.name}: {original_val} != {result_val}" class TestAssemblerParsing: @@ -603,17 +605,63 @@ def test_parse_comments(self): def test_parse_all_no_arg_ops(self): """Test that all no-arg operations can be parsed.""" ops = [ - "no_op", "siext_8_64", "siext_16_64", "siext_32_64", - "ziext_8_64", "ziext_16_64", "ziext_32_64", - "itrunc_64_8", "itrunc_64_16", "itrunc_64_32", - "fmod", "smod", "umod", "add", "sub", "mul", "udiv", "sdiv", - "fadd", "fsub", "fmul", "fpow", "fdiv", "flog", - "wait_rel", "wait_abs", "or", "and", - "ieq", "ine", "ult", "ule", "ugt", "uge", - "slt", "sle", "sgt", "sge", - "fge", "fle", "flt", "fgt", "feq", "fne", - "not", "fptrunc", "fpext", "fptosi", "sitofp", "fptoui", "uitofp", - "exit", "push_time", "push_rand", "set_seed", "call", "peek", + "no_op", + "siext_8_64", + "siext_16_64", + "siext_32_64", + "ziext_8_64", + "ziext_16_64", + "ziext_32_64", + "itrunc_64_8", + "itrunc_64_16", + "itrunc_64_32", + "fmod", + "smod", + "umod", + "add", + "sub", + "mul", + "udiv", + "sdiv", + "fadd", + "fsub", + "fmul", + "fpow", + "fdiv", + "flog", + "wait_rel", + "wait_abs", + "or", + "and", + "ieq", + "ine", + "ult", + "ule", + "ugt", + "uge", + "slt", + "sle", + "sgt", + "sge", + "fge", + "fle", + "flt", + "fgt", + "feq", + "fne", + "not", + "fptrunc", + "fpext", + "fptosi", + "sitofp", + "fptoui", + "uitofp", + "exit", + "push_time", + "push_rand", + "set_seed", + "call", + "peek", ] for op in ops: text = f"{op}\n" @@ -680,7 +728,7 @@ def test_disassemble_multiple(self): ExitDirective(), ] text = fpybc_directives_to_fpyasm(dirs) - lines = [line for line in text.strip().split('\n') if line] + lines = [line for line in text.strip().split("\n") if line] assert len(lines) == 3 assert lines[0] == "allocate 16" assert lines[1] == "no_op" @@ -722,17 +770,63 @@ def test_roundtrip_complex_sequence(self): def test_roundtrip_all_no_arg_ops(self): """Test round-trip for all no-arg operations.""" ops = [ - "no_op", "siext_8_64", "siext_16_64", "siext_32_64", - "ziext_8_64", "ziext_16_64", "ziext_32_64", - "itrunc_64_8", "itrunc_64_16", "itrunc_64_32", - "fmod", "smod", "umod", "add", "sub", "mul", "udiv", "sdiv", - "fadd", "fsub", "fmul", "fpow", "fdiv", "flog", - "wait_rel", "wait_abs", "or", "and", - "ieq", "ine", "ult", "ule", "ugt", "uge", - "slt", "sle", "sgt", "sge", - "fge", "fle", "flt", "fgt", "feq", "fne", - "not", "fptrunc", "fpext", "fptosi", "sitofp", "fptoui", "uitofp", - "exit", "push_time", "push_rand", "set_seed", "call", "peek", + "no_op", + "siext_8_64", + "siext_16_64", + "siext_32_64", + "ziext_8_64", + "ziext_16_64", + "ziext_32_64", + "itrunc_64_8", + "itrunc_64_16", + "itrunc_64_32", + "fmod", + "smod", + "umod", + "add", + "sub", + "mul", + "udiv", + "sdiv", + "fadd", + "fsub", + "fmul", + "fpow", + "fdiv", + "flog", + "wait_rel", + "wait_abs", + "or", + "and", + "ieq", + "ine", + "ult", + "ule", + "ugt", + "uge", + "slt", + "sle", + "sgt", + "sge", + "fge", + "fle", + "flt", + "fgt", + "feq", + "fne", + "not", + "fptrunc", + "fpext", + "fptosi", + "sitofp", + "fptoui", + "uitofp", + "exit", + "push_time", + "push_rand", + "set_seed", + "call", + "peek", ] for op in ops: self._test_text_roundtrip(f"{op}\n") @@ -742,19 +836,19 @@ def _test_text_roundtrip(self, original_text: str): # Parse and assemble body = fpybc_parse(original_text) dirs = assemble(body) - + # Disassemble back to text result_text = fpybc_directives_to_fpyasm(dirs) - + # Parse again and compare directives body2 = fpybc_parse(result_text) dirs2 = assemble(body2) - + assert len(dirs) == len(dirs2) for d1, d2 in zip(dirs, dirs2): assert type(d1) == type(d2) for field in fields(d1): - if field.name in ('meta', 'id'): + if field.name in ("meta", "id"): continue v1 = getattr(d1, field.name) v2 = getattr(d2, field.name) @@ -865,31 +959,35 @@ def _test_full_roundtrip(self, original_text: str): # Step 1: Parse and assemble original text to directives body = fpybc_parse(original_text) dirs = assemble(body) - + # Step 2: Serialize directives to binary binary, _ = serialize_directives(dirs) - + # Step 3: Deserialize binary back to directives dirs2, arg_type_names = deserialize_directives(binary) assert arg_type_names == [] - + # Step 4: Convert directives back to text result_text = fpybc_directives_to_fpyasm(dirs2) - + # Step 5: Parse the result text and compare directives body3 = fpybc_parse(result_text) dirs3 = assemble(body3) - + # Compare original directives with final directives assert len(dirs) == len(dirs3), f"Length mismatch: {len(dirs)} != {len(dirs3)}" for i, (d1, d3) in enumerate(zip(dirs, dirs3)): - assert type(d1) == type(d3), f"Type mismatch at {i}: {type(d1)} != {type(d3)}" + assert type(d1) == type( + d3 + ), f"Type mismatch at {i}: {type(d1)} != {type(d3)}" for field in fields(d1): - if field.name in ('meta', 'id'): + if field.name in ("meta", "id"): continue v1 = getattr(d1, field.name) v3 = getattr(d3, field.name) - assert v1 == v3, f"Field {field.name} mismatch at directive {i}: {v1} != {v3}" + assert ( + v1 == v3 + ), f"Field {field.name} mismatch at directive {i}: {v1} != {v3}" class TestEdgeCases: @@ -962,7 +1060,7 @@ def test_serialize_multiple_directives(self): ] serialized, _ = serialize_directives(dirs) deserialized, _ = deserialize_directives(serialized) - + assert len(deserialized) == len(dirs) for orig, d in zip(dirs, deserialized): assert type(orig) == type(d) @@ -1005,14 +1103,31 @@ def test_single_primitive_type(self): def test_multiple_primitive_types(self): dirs = [NoOpDirective()] - specs = [("a", "U8", 1), ("b", "U16", 2), ("c", "U32", 4), ("d", "U64", 8), ("e", "I8", 1), ("f", "I16", 2), ("g", "I32", 4), ("h", "I64", 8), ("i", "F32", 4), ("j", "F64", 8), ("k", "bool", 1)] + specs = [ + ("a", "U8", 1), + ("b", "U16", 2), + ("c", "U32", 4), + ("d", "U64", 8), + ("e", "I8", 1), + ("f", "I16", 2), + ("g", "I32", 4), + ("h", "I64", 8), + ("i", "F32", 4), + ("j", "F64", 8), + ("k", "bool", 1), + ] serialized, _ = serialize_directives(dirs, arg_specs=specs) _, arg_specs = deserialize_directives(serialized) assert arg_specs == specs def test_qualified_type_names(self): dirs = [NoOpDirective()] - specs = [("x", "U32", 4), ("rec", "Svc.DpRecord", 128), ("arr", "Ref.DpDemo.U32Array", 256), ("en", "Fw.Enabled", 1)] + specs = [ + ("x", "U32", 4), + ("rec", "Svc.DpRecord", 128), + ("arr", "Ref.DpDemo.U32Array", 256), + ("en", "Fw.Enabled", 1), + ] serialized, _ = serialize_directives(dirs, arg_specs=specs) _, arg_specs = deserialize_directives(serialized) assert arg_specs == specs @@ -1091,4 +1206,3 @@ def test_bad_crc_rejected(self): corrupted[-1] ^= 0x01 with pytest.raises(RuntimeError, match="CRC mismatch"): deserialize_directives(bytes(corrupted)) - diff --git a/test/fpy/test_boolean_logic.py b/test/fpy/test_boolean_logic.py index 0319d6b..d9abdc0 100644 --- a/test/fpy/test_boolean_logic.py +++ b/test/fpy/test_boolean_logic.py @@ -71,6 +71,7 @@ def test_not_true(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestComplexExpressions: def test_complex_and_or_not(self, fprime_test_api): @@ -119,6 +120,7 @@ def test_bool_stack_value(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestShortCircuit: def test_and_short_circuit_skips_rhs(self, fprime_test_api): @@ -147,6 +149,7 @@ def boom() -> bool: assert_run_success(fprime_test_api, seq) + class TestNonBoolOperands: def test_and_non_bool_operands(self, fprime_test_api): diff --git a/test/fpy/test_check.py b/test/fpy/test_check.py index 72b352f..b703cb5 100644 --- a/test/fpy/test_check.py +++ b/test/fpy/test_check.py @@ -51,8 +51,8 @@ def check_condition() -> bool: def test_check_condition_must_persist(self, fprime_test_api): """Test that condition must remain true for the full persist duration. - If condition becomes false before persist duration, the timer resets. - """ + If condition becomes false before persist duration, the timer resets. + """ seq = """ # Condition returns true twice, then false, then true forever call_count: I64 = 0 @@ -97,6 +97,7 @@ def return_true_once() -> bool: """ assert_run_success(fprime_test_api, seq, timeout_s=6) + class TestCheckBodies: def test_check_body_runs_on_success(self, fprime_test_api): @@ -121,6 +122,7 @@ def test_check_timeout_body_runs_on_timeout(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestCheckClauses: def test_check_no_timeout_clause(self, fprime_test_api): @@ -164,6 +166,7 @@ def test_check_absolute_timeout(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestCheckNesting: def test_check_nested_in_function(self, fprime_test_api): @@ -256,6 +259,7 @@ def test_check_inside_for_loop(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestCheckTypeErrors: def test_check_timeout_wrong_type(self, fprime_test_api): @@ -314,6 +318,7 @@ def test_check_freq_wrong_type_time(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestCheckDuplicateClauses: def test_check_duplicate_timeout(self, fprime_test_api): @@ -340,6 +345,7 @@ def test_check_duplicate_freq(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestCheckMultilineSyntax: def test_check_multiline_timeout_only(self, fprime_test_api): @@ -484,6 +490,7 @@ def test_check_mixed_two_inline_one_multiline(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestCheckControlFlow: def test_check_break_not_allowed(self, fprime_test_api): @@ -579,7 +586,8 @@ def check_needs_return() -> I64: def test_check_no_timeout_clause_still_needs_return(self, fprime_test_api): """Test that a check with no timeout clause still needs a trailing return - because the desugared if/else has an implicit else branch that doesn't return.""" + because the desugared if/else has an implicit else branch that doesn't return. + """ seq = """ def check_no_timeout() -> I64: check True: diff --git a/test/fpy/test_comparisons.py b/test/fpy/test_comparisons.py index ce74a53..4ced8c6 100644 --- a/test/fpy/test_comparisons.py +++ b/test/fpy/test_comparisons.py @@ -40,6 +40,7 @@ def test_literal_comparison_false(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestCrossTypeComparisons: def test_f32_f64_cmp(self, fprime_test_api): @@ -85,20 +86,24 @@ def test_float_int_literal_cmp(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestAllOperatorsByType: - @pytest.mark.parametrize("type_name,big,small", [ - ("U8", "200", "100"), - ("I8", "100", "-100"), - ("U16", "60000", "100"), - ("I16", "30000", "-30000"), - ("U32", "4294967295", "0"), - ("I32", "2147483647", "-2147483648"), - ("U64", "4294967295", "0"), - ("I64", "2147483647", "-2147483648"), - ("F32", "3.14159", "-3.14159"), - ("F64", "3.14159265359", "-3.14159265359"), - ]) + @pytest.mark.parametrize( + "type_name,big,small", + [ + ("U8", "200", "100"), + ("I8", "100", "-100"), + ("U16", "60000", "100"), + ("I16", "30000", "-30000"), + ("U32", "4294967295", "0"), + ("I32", "2147483647", "-2147483648"), + ("U64", "4294967295", "0"), + ("I64", "2147483647", "-2147483648"), + ("F32", "3.14159", "-3.14159"), + ("F64", "3.14159265359", "-3.14159265359"), + ], + ) def test_all_comparison_operators(self, fprime_test_api, type_name, big, small): seq = f""" val1: {type_name} = {big} @@ -112,6 +117,7 @@ def test_all_comparison_operators(self, fprime_test_api, type_name, big, small): """ assert_run_success(fprime_test_api, seq) + class TestEdgeCases: def test_equality_edge_cases(self, fprime_test_api): diff --git a/test/fpy/test_compiler_config.py b/test/fpy/test_compiler_config.py index bdcb82d..91c90ca 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -26,7 +26,6 @@ from fpy.dictionary import load_dictionary from fpy.types import DEFAULT_MAX_DIRECTIVES_COUNT, DEFAULT_MAX_DIRECTIVE_SIZE - # Path to the test dictionary DEFAULT_DICTIONARY = str(Path(__file__).parent / "RefTopologyDictionary.json") @@ -73,15 +72,14 @@ def create_test_dictionary(constants: list[dict]) -> str: # that aren't being overridden test_constant_names = {c["qualifiedName"] for c in constants} filtered_constants = [ - c for c in base_dict.get("constants", []) + c + for c in base_dict.get("constants", []) if c.get("qualifiedName") not in test_constant_names ] base_dict["constants"] = filtered_constants + constants # Write to temp file - temp_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) json.dump(base_dict, temp_file) temp_file.close() @@ -93,15 +91,17 @@ def test_custom_max_directives_count(): _clear_caches() custom_count = 500 - dict_path = create_test_dictionary([ - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_count, - "annotation": "Custom max sequence statement count" - } - ]) + dict_path = create_test_dictionary( + [ + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_count, + "annotation": "Custom max sequence statement count", + } + ] + ) try: state = get_base_compile_state(dict_path, {}) @@ -118,15 +118,17 @@ def test_custom_max_directive_size(): _clear_caches() custom_size = 4096 - dict_path = create_test_dictionary([ - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_DIRECTIVE_SIZE", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_size, - "annotation": "Custom max directive size" - } - ]) + dict_path = create_test_dictionary( + [ + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_DIRECTIVE_SIZE", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_size, + "annotation": "Custom max directive size", + } + ] + ) try: state = get_base_compile_state(dict_path, {}) @@ -144,22 +146,24 @@ def test_custom_both_limits(): custom_count = 256 custom_size = 1024 - dict_path = create_test_dictionary([ - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_count, - "annotation": "Custom max sequence statement count" - }, - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_DIRECTIVE_SIZE", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_size, - "annotation": "Custom max directive size" - } - ]) + dict_path = create_test_dictionary( + [ + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_count, + "annotation": "Custom max sequence statement count", + }, + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_DIRECTIVE_SIZE", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_size, + "annotation": "Custom max directive size", + }, + ] + ) try: state = get_base_compile_state(dict_path, {}) @@ -182,7 +186,8 @@ def test_missing_constants_use_defaults(): dict_json = json.load(f) dict_json["constants"] = [ - c for c in dict_json.get("constants", []) + c + for c in dict_json.get("constants", []) if not c.get("qualifiedName", "").startswith("Svc.Fpy.") ] @@ -204,15 +209,17 @@ def test_too_many_directives_with_custom_limit(): # Set a very low limit custom_count = 5 - dict_path = create_test_dictionary([ - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_count, - "annotation": "Very low limit for testing" - } - ]) + dict_path = create_test_dictionary( + [ + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_count, + "annotation": "Very low limit for testing", + } + ] + ) try: # This sequence has more than 5 directives when compiled @@ -239,15 +246,17 @@ def test_within_custom_limit_succeeds(): # Set a reasonable limit custom_count = 100 - dict_path = create_test_dictionary([ - { - "kind": "constant", - "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", - "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, - "value": custom_count, - "annotation": "Reasonable limit for testing" - } - ]) + dict_path = create_test_dictionary( + [ + { + "kind": "constant", + "qualifiedName": "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", + "type": {"name": "U64", "kind": "integer", "size": 64, "signed": False}, + "value": custom_count, + "annotation": "Reasonable limit for testing", + } + ] + ) try: # This sequence should be within the limit @@ -288,9 +297,7 @@ def create_test_dict_with_timebase( type_def["representationType"] = rep_type break - temp_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) json.dump(base_dict, temp_file) temp_file.close() return temp_file.name @@ -303,13 +310,12 @@ def create_test_dict_without_timebase() -> str: # Remove TimeBase from typeDefinitions base_dict["typeDefinitions"] = [ - t for t in base_dict.get("typeDefinitions", []) + t + for t in base_dict.get("typeDefinitions", []) if t.get("qualifiedName") != "TimeBase" ] - temp_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) json.dump(base_dict, temp_file) temp_file.close() return temp_file.name @@ -373,6 +379,7 @@ def test_timebase_missing_raises_error(): try: import pytest + # Dictionary parsing fails because Fw.TimeValue depends on TimeBase with pytest.raises(AssertionError, match="Could not resolve types"): get_base_compile_state(dict_path, {}) @@ -395,6 +402,7 @@ def test_timebase_missing_tb_none_raises_error(): try: import pytest from fpy.error import DictionaryError + with pytest.raises(DictionaryError, match="must include TB_NONE"): get_base_compile_state(dict_path, {}) finally: @@ -416,6 +424,7 @@ def test_timebase_tb_none_wrong_value_raises_error(): try: import pytest from fpy.error import DictionaryError + with pytest.raises(DictionaryError, match="TB_NONE constant must have value 0"): get_base_compile_state(dict_path, {}) finally: diff --git a/test/fpy/test_control_flow.py b/test/fpy/test_control_flow.py index 079e46c..34cb337 100644 --- a/test/fpy/test_control_flow.py +++ b/test/fpy/test_control_flow.py @@ -22,6 +22,7 @@ def test_exit_failure(self, fprime_test_api): """ assert_run_failure(fprime_test_api, seq, 123) + class TestIf: def test_simple_if(self, fprime_test_api): @@ -104,6 +105,7 @@ def test_if_elif_else(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestBreakContinueErrors: def test_break_outside_loop(self, fprime_test_api): @@ -131,6 +133,7 @@ def test_deeply_nested_loops_exhaust_recursion_depth(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + class TestForLoops: def test_simple_for(self, fprime_test_api): @@ -269,6 +272,7 @@ def test_empty_range(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestLoopVariableScoping: def test_loop_var_outside_loop_after(self, fprime_test_api): @@ -361,6 +365,7 @@ def test_use_loop_var_in_bounds(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + class TestWhileLoops: def test_while_break_in_if(self, fprime_test_api): @@ -389,6 +394,7 @@ def test_while_continue_in_if(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestNonBoolConditions: def test_if_non_bool_condition(self, fprime_test_api): diff --git a/test/fpy/test_depend.py b/test/fpy/test_depend.py index 4bb489b..0905208 100644 --- a/test/fpy/test_depend.py +++ b/test/fpy/test_depend.py @@ -5,6 +5,7 @@ yet — the whole point of the tool is to let build systems determine compile order before any binaries have been produced. """ + import tempfile from pathlib import Path diff --git a/test/fpy/test_dictionary.py b/test/fpy/test_dictionary.py index 5792792..b7a16ac 100644 --- a/test/fpy/test_dictionary.py +++ b/test/fpy/test_dictionary.py @@ -1,4 +1,3 @@ - import json import os import tempfile @@ -394,13 +393,19 @@ def test_array_size_1(self): "kind": "array", "qualifiedName": "M.Single", "size": 1, - "elementType": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "elementType": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "default": [0], } ] result = _parse_type_definitions(raw) assert result["M.Single"].length == 1 + class TestTypeDefEnum: """Spec §Type Definitions / Enumeration Type Definition.""" @@ -437,7 +442,12 @@ def test_enum_representation_type(self): { "kind": "enum", "qualifiedName": "M.E", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "enumeratedConstants": [{"name": "A", "value": 0}], "default": "M.E.A", } @@ -445,7 +455,9 @@ def test_enum_representation_type(self): result = _parse_type_definitions(raw) assert result["M.E"].rep_type is U8 - @pytest.mark.parametrize("rep", ["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64"]) + @pytest.mark.parametrize( + "rep", ["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64"] + ) def test_all_valid_enum_rep_types(self, rep): """Spec allows U8-U64, I8-I64 as representation types.""" signed = rep.startswith("I") @@ -454,7 +466,12 @@ def test_all_valid_enum_rep_types(self, rep): { "kind": "enum", "qualifiedName": f"M.E_{rep}", - "representationType": {"name": rep, "kind": "integer", "size": size, "signed": signed}, + "representationType": { + "name": rep, + "kind": "integer", + "size": size, + "signed": signed, + }, "enumeratedConstants": [{"name": "X", "value": 0}], "default": f"M.E_{rep}.X", } @@ -468,7 +485,12 @@ def test_enum_many_constants(self): { "kind": "enum", "qualifiedName": "M.Big", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "enumeratedConstants": consts, "default": "M.Big.C0", } @@ -490,13 +512,23 @@ def test_struct_basic(self): "annotation": "Struct", "members": { "w": { - "type": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, + "type": { + "name": "U32", + "kind": "integer", + "signed": False, + "size": 32, + }, "index": 0, "size": 3, "annotation": "This is an array", }, "x": { - "type": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, + "type": { + "name": "U32", + "kind": "integer", + "signed": False, + "size": 32, + }, "format": "the count is {}", "index": 1, }, @@ -529,9 +561,33 @@ def test_struct_member_order_by_index(self): "kind": "struct", "qualifiedName": "M.Rev", "members": { - "z": {"type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "index": 2}, - "a": {"type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "index": 0}, - "m": {"type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "index": 1}, + "z": { + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "index": 2, + }, + "a": { + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "index": 0, + }, + "m": { + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "index": 1, + }, }, "default": {"z": 0, "a": 0, "m": 0}, } @@ -545,16 +601,35 @@ def test_struct_with_enum_member(self): { "kind": "enum", "qualifiedName": "M.Status", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, - "enumeratedConstants": [{"name": "OK", "value": 0}, {"name": "ERR", "value": 1}], + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "enumeratedConstants": [ + {"name": "OK", "value": 0}, + {"name": "ERR", "value": 1}, + ], "default": "M.Status.OK", }, { "kind": "struct", "qualifiedName": "M.Result", "members": { - "code": {"type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "index": 0}, - "status": {"type": {"name": "M.Status", "kind": "qualifiedIdentifier"}, "index": 1}, + "code": { + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, + "index": 0, + }, + "status": { + "type": {"name": "M.Status", "kind": "qualifiedIdentifier"}, + "index": 1, + }, }, "default": {"code": 0, "status": "M.Status.OK"}, }, @@ -569,7 +644,15 @@ def test_struct_single_member(self): "kind": "struct", "qualifiedName": "M.One", "members": { - "only": {"type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "index": 0}, + "only": { + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, + "index": 0, + }, }, "default": {"only": 0}, } @@ -591,8 +674,14 @@ def test_struct_referencing_array(self): "kind": "struct", "qualifiedName": "M.Pose", "members": { - "position": {"type": {"name": "M.Vec3", "kind": "qualifiedIdentifier"}, "index": 0}, - "heading": {"type": {"name": "F32", "kind": "float", "size": 32}, "index": 1}, + "position": { + "type": {"name": "M.Vec3", "kind": "qualifiedIdentifier"}, + "index": 0, + }, + "heading": { + "type": {"name": "F32", "kind": "float", "size": 32}, + "index": 1, + }, }, "default": {"position": [0.0, 0.0, 0.0], "heading": 0.0}, }, @@ -613,7 +702,12 @@ def test_alias_to_primitive(self): "kind": "alias", "qualifiedName": "M1.A1", "type": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, - "underlyingType": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, + "underlyingType": { + "name": "U32", + "kind": "integer", + "signed": False, + "size": 32, + }, "annotation": "Alias of type U32", } ] @@ -627,13 +721,23 @@ def test_alias_chain(self): "kind": "alias", "qualifiedName": "M1.A1", "type": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, - "underlyingType": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, + "underlyingType": { + "name": "U32", + "kind": "integer", + "signed": False, + "size": 32, + }, }, { "kind": "alias", "qualifiedName": "M1.A2", "type": {"name": "M1.A1", "kind": "qualifiedIdentifier"}, - "underlyingType": {"name": "U32", "kind": "integer", "signed": False, "size": 32}, + "underlyingType": { + "name": "U32", + "kind": "integer", + "signed": False, + "size": 32, + }, }, ] result = _parse_type_definitions(raw) @@ -645,7 +749,12 @@ def test_alias_to_enum(self): { "kind": "enum", "qualifiedName": "M.E", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "enumeratedConstants": [{"name": "A", "value": 0}], "default": "M.E.A", }, @@ -672,7 +781,12 @@ def test_alias_reverse_order(self): "kind": "alias", "qualifiedName": "M.B", "type": {"name": "U32", "kind": "integer"}, - "underlyingType": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "underlyingType": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, }, ] result = _parse_type_definitions(raw) @@ -714,8 +828,14 @@ def test_alias_to_struct(self): "kind": "struct", "qualifiedName": "M.Point", "members": { - "x": {"type": {"name": "F32", "kind": "float", "size": 32}, "index": 0}, - "y": {"type": {"name": "F32", "kind": "float", "size": 32}, "index": 1}, + "x": { + "type": {"name": "F32", "kind": "float", "size": 32}, + "index": 0, + }, + "y": { + "type": {"name": "F32", "kind": "float", "size": 32}, + "index": 1, + }, }, "default": {"x": 0.0, "y": 0.0}, }, @@ -732,7 +852,12 @@ def test_array_of_alias_to_array(self): "kind": "array", "qualifiedName": "M.Inner", "size": 2, - "elementType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "elementType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "default": [0, 0], }, { @@ -777,7 +902,15 @@ def test_struct_referencing_struct(self): "kind": "struct", "qualifiedName": "M.Inner", "members": { - "a": {"type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "index": 0}, + "a": { + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, + "index": 0, + }, }, "default": {"a": 0}, }, @@ -785,7 +918,10 @@ def test_struct_referencing_struct(self): "kind": "struct", "qualifiedName": "M.Outer", "members": { - "inner": {"type": {"name": "M.Inner", "kind": "qualifiedIdentifier"}, "index": 0}, + "inner": { + "type": {"name": "M.Inner", "kind": "qualifiedIdentifier"}, + "index": 0, + }, "flag": {"type": {"name": "bool", "kind": "bool"}, "index": 1}, }, "default": {"inner": {"a": 0}, "flag": False}, @@ -800,7 +936,12 @@ def test_array_of_enum(self): { "kind": "enum", "qualifiedName": "M.Dir", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "enumeratedConstants": [ {"name": "UP", "value": 0}, {"name": "DOWN", "value": 1}, @@ -824,8 +965,14 @@ def test_array_of_struct(self): "kind": "struct", "qualifiedName": "M.Point", "members": { - "x": {"type": {"name": "F32", "kind": "float", "size": 32}, "index": 0}, - "y": {"type": {"name": "F32", "kind": "float", "size": 32}, "index": 1}, + "x": { + "type": {"name": "F32", "kind": "float", "size": 32}, + "index": 0, + }, + "y": { + "type": {"name": "F32", "kind": "float", "size": 32}, + "index": 1, + }, }, "default": {"x": 0.0, "y": 0.0}, }, @@ -863,7 +1010,16 @@ class TestValuePrimitiveInteger: @pytest.mark.parametrize( "typ,val", - [(U8, 2), (U16, 1000), (U32, 100000), (U64, 2**60), (I8, -2), (I16, -100), (I32, -100000), (I64, -(2**60))], + [ + (U8, 2), + (U16, 1000), + (U32, 100000), + (U64, 2**60), + (I8, -2), + (I16, -100), + (I32, -100000), + (I64, -(2**60)), + ], ) def test_integer_values(self, typ, val): result = json_default_to_fpy_value(val, typ) @@ -1043,9 +1199,7 @@ def test_struct_with_array_member(self): "M.Pose", members=(StructMember("pos", arr), StructMember("heading", F32)), ) - result = json_default_to_fpy_value( - {"pos": [1.0, 2.0, 3.0], "heading": 0.5}, st - ) + result = json_default_to_fpy_value({"pos": [1.0, 2.0, 3.0], "heading": 0.5}, st) assert len(result.val["pos"].val) == 3 assert result.val["heading"].val == 0.5 @@ -1086,9 +1240,7 @@ def test_array_of_structs(self): members=(StructMember("x", I32), StructMember("y", I32)), ) arr = FpyType(TypeKind.ARRAY, "M.Pts", elem_type=point, length=2) - result = json_default_to_fpy_value( - [{"x": 1, "y": 2}, {"x": 3, "y": 4}], arr - ) + result = json_default_to_fpy_value([{"x": 1, "y": 2}, {"x": 3, "y": 4}], arr) assert len(result.val) == 2 assert result.val[0].val["x"] == FpyValue(I32, 1) assert result.val[1].val["y"] == FpyValue(I32, 4) @@ -1114,7 +1266,12 @@ def test_sync_command_with_params(self): { "name": "param1", "annotation": "Param 1", - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "ref": False, }, { @@ -1230,7 +1387,12 @@ def test_command_with_enum_arg(self): assert id_dict[55].arguments[0][2].kind == TypeKind.ENUM def test_command_with_array_arg(self): - arr = FpyType(TypeKind.ARRAY, "M.StringArray", elem_type=FpyType(TypeKind.STRING, "String_80", max_length=80), length=2) + arr = FpyType( + TypeKind.ARRAY, + "M.StringArray", + elem_type=FpyType(TypeKind.STRING, "String_80", max_length=80), + length=2, + ) raw = [ { "name": "M.c1.CommandString", @@ -1239,7 +1401,10 @@ def test_command_with_array_arg(self): "formalParams": [ { "name": "arg1", - "type": {"name": "M.StringArray", "kind": "qualifiedIdentifier"}, + "type": { + "name": "M.StringArray", + "kind": "qualifiedIdentifier", + }, "ref": False, "annotation": "description for argument 1", } @@ -1254,7 +1419,12 @@ def test_multiple_commands(self): raw = [ {"name": "A.CMD1", "commandKind": "async", "opcode": 1, "formalParams": []}, {"name": "A.CMD2", "commandKind": "sync", "opcode": 2, "formalParams": []}, - {"name": "B.CMD3", "commandKind": "guarded", "opcode": 3, "formalParams": []}, + { + "name": "B.CMD3", + "commandKind": "guarded", + "opcode": 3, + "formalParams": [], + }, ] id_dict, name_dict = _parse_commands(raw, {}) assert len(id_dict) == 3 @@ -1360,7 +1530,9 @@ def test_channel_string_type(self): assert ch.ch_type.max_length == 256 def test_channel_enum_type(self): - e = FpyType(TypeKind.ENUM, "M.Status", enum_dict={"OK": 0, "ERR": 1}, rep_type=U8) + e = FpyType( + TypeKind.ENUM, "M.Status", enum_dict={"OK": 0, "ERR": 1}, rep_type=U8 + ) raw = [ { "name": "M.c1.Status", @@ -1373,9 +1545,21 @@ def test_channel_enum_type(self): def test_multiple_channels(self): raw = [ - {"name": "A.Ch1", "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "id": 1}, - {"name": "A.Ch2", "type": {"name": "U16", "kind": "integer", "size": 16, "signed": False}, "id": 2}, - {"name": "B.Ch3", "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "id": 3}, + { + "name": "A.Ch1", + "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "id": 1, + }, + { + "name": "A.Ch2", + "type": {"name": "U16", "kind": "integer", "size": 16, "signed": False}, + "id": 2, + }, + { + "name": "B.Ch3", + "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "id": 3, + }, ] id_dict, name_dict = _parse_channels(raw, {}) assert len(id_dict) == 3 @@ -1460,8 +1644,16 @@ def test_parameter_enum_type(self): def test_multiple_parameters(self): raw = [ - {"name": "A.P1", "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "id": 1}, - {"name": "A.P2", "type": {"name": "U16", "kind": "integer", "size": 16, "signed": False}, "id": 2}, + { + "name": "A.P1", + "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "id": 1, + }, + { + "name": "A.P2", + "type": {"name": "U16", "kind": "integer", "size": 16, "signed": False}, + "id": 2, + }, ] id_dict, name_dict = _parse_parameters(raw, {}) assert len(id_dict) == 2 @@ -1552,7 +1744,12 @@ def test_full_dictionary_from_spec(self): { "kind": "enum", "qualifiedName": "M.StatusEnum", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "enumeratedConstants": [ {"name": "YES", "value": 0}, {"name": "NO", "value": 1}, @@ -1565,7 +1762,12 @@ def test_full_dictionary_from_spec(self): "qualifiedName": "M.A", "members": { "x": { - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "index": 0, "format": "The value of x is {}", }, @@ -1586,7 +1788,10 @@ def test_full_dictionary_from_spec(self): "formalParams": [ { "name": "arg1", - "type": {"name": "M.StringArray", "kind": "qualifiedIdentifier"}, + "type": { + "name": "M.StringArray", + "kind": "qualifiedIdentifier", + }, "ref": False, "annotation": "description for argument 1", } @@ -1630,7 +1835,10 @@ def test_full_dictionary_from_spec(self): "formalParams": [ { "name": "arg1", - "type": {"name": "M.StatusEnum", "kind": "qualifiedIdentifier"}, + "type": { + "name": "M.StatusEnum", + "kind": "qualifiedIdentifier", + }, "ref": False, "annotation": "Description of arg1 formal param", } @@ -1643,7 +1851,12 @@ def test_full_dictionary_from_spec(self): telemetryChannels=[ { "name": "M.c1.Channel1", - "type": {"name": "I32", "kind": "integer", "size": 32, "signed": True}, + "type": { + "name": "I32", + "kind": "integer", + "size": 32, + "signed": True, + }, "id": 260, "telemetryUpdate": "on change", "annotation": "Telemetry channel 1 of type I32", @@ -1687,14 +1900,42 @@ def test_dictionary_id_and_name_dicts_consistent(self): """Every entry in name_dict should appear in id_dict and vice versa.""" data = _minimal_dict( commands=[ - {"name": "A.CMD1", "commandKind": "async", "opcode": 1, "formalParams": []}, - {"name": "A.CMD2", "commandKind": "sync", "opcode": 2, "formalParams": []}, + { + "name": "A.CMD1", + "commandKind": "async", + "opcode": 1, + "formalParams": [], + }, + { + "name": "A.CMD2", + "commandKind": "sync", + "opcode": 2, + "formalParams": [], + }, ], telemetryChannels=[ - {"name": "A.Ch1", "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "id": 10}, + { + "name": "A.Ch1", + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, + "id": 10, + }, ], parameters=[ - {"name": "A.P1", "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "id": 20}, + { + "name": "A.P1", + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "id": 20, + }, ], ) path = _write_dict(data) @@ -1727,7 +1968,12 @@ def test_formal_param_fields(self): "formalParams": [ { "name": "param1", - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "ref": False, "annotation": "This is param1", } @@ -1750,7 +1996,12 @@ def test_formal_param_no_annotation(self): "formalParams": [ { "name": "p", - "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "ref": False, } ], @@ -1769,7 +2020,14 @@ def test_many_formal_params(self): } for i in range(5) ] - raw = [{"name": "M.Cmd", "commandKind": "async", "opcode": 10, "formalParams": params}] + raw = [ + { + "name": "M.Cmd", + "commandKind": "async", + "opcode": 10, + "formalParams": params, + } + ] id_dict, _ = _parse_commands(raw, {}) assert len(id_dict[10].arguments) == 5 @@ -1891,12 +2149,22 @@ def test_struct_member_array(self): "qualifiedName": "M.WithArr", "members": { "data": { - "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "index": 0, "size": 5, }, "count": { - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "index": 1, }, }, @@ -1921,12 +2189,22 @@ def test_struct_member_array_deduplicate(self): "qualifiedName": "M.TwoArrays", "members": { "a": { - "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "index": 0, "size": 3, }, "b": { - "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "index": 1, "size": 3, }, @@ -1960,7 +2238,12 @@ def test_member_array_single_value_u8(self): "qualifiedName": "M.WithSingleInit", "members": { "data": { - "type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, "index": 0, "size": 3, }, @@ -2018,8 +2301,16 @@ def test_member_array_single_value_enum(self): { "kind": "enum", "qualifiedName": "M.Dir", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, - "enumeratedConstants": [{"name": "UP", "value": 0}, {"name": "DOWN", "value": 1}], + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "enumeratedConstants": [ + {"name": "UP", "value": 0}, + {"name": "DOWN", "value": 1}, + ], "default": "M.Dir.UP", }, { @@ -2055,7 +2346,12 @@ def test_member_array_list_default_NOT_replicated(self): "qualifiedName": "M.Normal", "members": { "data": { - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "index": 0, "size": 3, }, @@ -2084,12 +2380,22 @@ def test_member_array_scalar_alongside_normal_members(self): "qualifiedName": "M.Mixed", "members": { "arr": { - "type": {"name": "I32", "kind": "integer", "size": 32, "signed": True}, + "type": { + "name": "I32", + "kind": "integer", + "size": 32, + "signed": True, + }, "index": 0, "size": 2, }, "x": { - "type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, "index": 1, }, "y": { @@ -2148,6 +2454,7 @@ class TestSingleValueArrayInitIntegration: def clear_caches(self): load_dictionary.cache_clear() from fpy.state import _build_global_scopes + _build_global_scopes.cache_clear() yield load_dictionary.cache_clear() @@ -2155,31 +2462,38 @@ def clear_caches(self): def _get_type_scope(self): from fpy.state import _build_global_scopes + type_scope, _, _, _ = _build_global_scopes(REF_DICT_PATH) return type_scope def _get_callable_scope(self): from fpy.state import _build_global_scopes + _, callable_scope, _, _ = _build_global_scopes(REF_DICT_PATH) return callable_scope def _lookup_type(self, name: str) -> FpyType: scope = self._get_type_scope() for part in name.split("."): - assert part in scope, f"'{part}' not found in scope while looking up '{name}'" + assert ( + part in scope + ), f"'{part}' not found in scope while looking up '{name}'" scope = scope[part] return scope def _lookup_callable(self, name: str): scope = self._get_callable_scope() for part in name.split("."): - assert part in scope, f"'{part}' not found in scope while looking up '{name}'" + assert ( + part in scope + ), f"'{part}' not found in scope while looking up '{name}'" scope = scope[part] return scope def test_choice_slurry_member_array_replicated(self): """Ref.ChoiceSlurry.choiceAsMemberArray has size=2, default=0. - _populate_type_defaults should replicate 0 → [FpyValue(U8,0), FpyValue(U8,0)].""" + _populate_type_defaults should replicate 0 → [FpyValue(U8,0), FpyValue(U8,0)]. + """ typ = self._lookup_type("Ref.ChoiceSlurry") assert typ.kind == TypeKind.STRUCT @@ -2490,7 +2804,9 @@ def test_array_type_parsed(self): def test_constants_values(self): """Spot-check known constants from the Ref dictionary.""" d = load_dictionary(REF_DICT_PATH) - assert d["constants"]["Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT"] == FpyValue(U64, 2048) + assert d["constants"]["Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT"] == FpyValue( + U64, 2048 + ) assert d["constants"]["Svc.Fpy.MAX_DIRECTIVE_SIZE"] == FpyValue(U64, 2048) def test_metadata_present(self): @@ -2558,13 +2874,21 @@ def test_chained_aliases(self): "kind": "alias", "qualifiedName": "Synth.AliasA", "type": {"name": "Synth.AliasB", "kind": "qualifiedIdentifier"}, - "underlyingType": {"name": "Synth.AliasB", "kind": "qualifiedIdentifier"}, + "underlyingType": { + "name": "Synth.AliasB", + "kind": "qualifiedIdentifier", + }, }, { "kind": "alias", "qualifiedName": "Synth.AliasB", "type": {"name": "U32", "kind": "integer"}, - "underlyingType": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, + "underlyingType": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, }, ] ) @@ -2582,16 +2906,38 @@ def test_struct_with_enum_member(self): { "kind": "enum", "qualifiedName": "Synth.Compass", - "representationType": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, - "enumeratedConstants": [{"name": "N", "value": 0}, {"name": "S", "value": 1}], + "representationType": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "enumeratedConstants": [ + {"name": "N", "value": 0}, + {"name": "S", "value": 1}, + ], "default": "Synth.Compass.N", }, { "kind": "struct", "qualifiedName": "Synth.Move", "members": { - "dir": {"type": {"name": "Synth.Compass", "kind": "qualifiedIdentifier"}, "index": 0}, - "dist": {"type": {"name": "U32", "kind": "integer", "size": 32, "signed": False}, "index": 1}, + "dir": { + "type": { + "name": "Synth.Compass", + "kind": "qualifiedIdentifier", + }, + "index": 0, + }, + "dist": { + "type": { + "name": "U32", + "kind": "integer", + "size": 32, + "signed": False, + }, + "index": 1, + }, }, "default": {"dir": "Synth.Compass.N", "dist": 0}, }, @@ -2617,8 +2963,24 @@ def test_command_with_struct_arg(self): "kind": "struct", "qualifiedName": "Synth.Pair", "members": { - "a": {"type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "index": 0}, - "b": {"type": {"name": "U8", "kind": "integer", "size": 8, "signed": False}, "index": 1}, + "a": { + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "index": 0, + }, + "b": { + "type": { + "name": "U8", + "kind": "integer", + "size": 8, + "signed": False, + }, + "index": 1, + }, }, "default": {"a": 0, "b": 0}, } @@ -2631,7 +2993,10 @@ def test_command_with_struct_arg(self): "formalParams": [ { "name": "pair", - "type": {"name": "Synth.Pair", "kind": "qualifiedIdentifier"}, + "type": { + "name": "Synth.Pair", + "kind": "qualifiedIdentifier", + }, "ref": False, } ], @@ -2669,9 +3034,24 @@ def test_string_type_in_channel(self): def test_multiple_commands_unique_opcodes(self): data = _minimal_dict( commands=[ - {"name": "A.b.CMD1", "commandKind": "async", "opcode": 1, "formalParams": []}, - {"name": "A.b.CMD2", "commandKind": "async", "opcode": 2, "formalParams": []}, - {"name": "A.c.CMD3", "commandKind": "async", "opcode": 3, "formalParams": []}, + { + "name": "A.b.CMD1", + "commandKind": "async", + "opcode": 1, + "formalParams": [], + }, + { + "name": "A.b.CMD2", + "commandKind": "async", + "opcode": 2, + "formalParams": [], + }, + { + "name": "A.c.CMD3", + "commandKind": "async", + "opcode": 3, + "formalParams": [], + }, ] ) path = _write_dict(data) @@ -2751,6 +3131,7 @@ class TestTypeCtorDefaults: def clear_caches(self): load_dictionary.cache_clear() from fpy.state import _build_global_scopes + _build_global_scopes.cache_clear() yield load_dictionary.cache_clear() @@ -2758,11 +3139,13 @@ def clear_caches(self): def _get_callable_scope(self): from fpy.state import _build_global_scopes + _, callable_scope, _, _ = _build_global_scopes(REF_DICT_PATH) return callable_scope def _get_type_scope(self): from fpy.state import _build_global_scopes + type_scope, _, _, _ = _build_global_scopes(REF_DICT_PATH) return type_scope @@ -2772,7 +3155,9 @@ def _lookup_callable(self, name: str): parts = name.split(".") current = scope for part in parts: - assert part in current, f"'{part}' not found in scope while looking up '{name}'" + assert ( + part in current + ), f"'{part}' not found in scope while looking up '{name}'" current = current[part] return current @@ -2782,7 +3167,9 @@ def _lookup_type(self, name: str) -> FpyType: parts = name.split(".") current = scope for part in parts: - assert part in current, f"'{part}' not found in scope while looking up '{name}'" + assert ( + part in current + ), f"'{part}' not found in scope while looking up '{name}'" current = current[part] return current @@ -2792,8 +3179,12 @@ def test_struct_member_defaults(self): assert typ.kind == TypeKind.STRUCT assert typ.member_defaults is not None for m in typ.members: - assert m.name in typ.member_defaults, f"Struct member '{m.name}' should have a default" - assert isinstance(typ.member_defaults[m.name], FpyValue), f"Default for '{m.name}' should be FpyValue" + assert ( + m.name in typ.member_defaults + ), f"Struct member '{m.name}' should have a default" + assert isinstance( + typ.member_defaults[m.name], FpyValue + ), f"Default for '{m.name}' should be FpyValue" def test_struct_member_default_values_correct(self): typ = self._lookup_type("Ref.SignalPair") @@ -2839,11 +3230,16 @@ def test_array_of_enums_elem_defaults(self): def test_struct_ctor_has_defaults(self): """Type ctor args should reflect the member defaults.""" from fpy.state import TypeCtorSymbol + ctor = self._lookup_callable("Ref.SignalPair") assert isinstance(ctor, TypeCtorSymbol) for arg_name, arg_type, arg_default in ctor.args: - assert arg_default is not None, f"Struct member '{arg_name}' should have a default" - assert isinstance(arg_default, FpyValue), f"Default for '{arg_name}' should be FpyValue" + assert ( + arg_default is not None + ), f"Struct member '{arg_name}' should have a default" + assert isinstance( + arg_default, FpyValue + ), f"Default for '{arg_name}' should be FpyValue" def test_struct_ctor_default_values_correct(self): ctor = self._lookup_callable("Ref.SignalPair") @@ -2866,6 +3262,7 @@ def test_struct_ctor_with_enum_member_default(self): def test_array_ctor_has_defaults(self): """Array type constructors should have FpyValue defaults for each element.""" from fpy.state import TypeCtorSymbol + ctor = self._lookup_callable("Svc.BuffQueueDepth") assert isinstance(ctor, TypeCtorSymbol) assert len(ctor.args) == 1 diff --git a/test/fpy/test_functions.py b/test/fpy/test_functions.py index 1811971..87064d1 100644 --- a/test/fpy/test_functions.py +++ b/test/fpy/test_functions.py @@ -90,6 +90,7 @@ def test(arg: U8): """ assert_run_success(fprime_test_api, seq) + class TestReturns: def test_return_outside_func(self, fprime_test_api): @@ -215,6 +216,7 @@ def test() -> U32: """ assert_compile_failure(fprime_test_api, seq) + class TestCalls: def test_wrong_arg_type(self, fprime_test_api): @@ -289,6 +291,7 @@ def noop(): """ assert_compile_failure(fprime_test_api, seq) + class TestScoping: def test_break_in_func_in_loop(self, fprime_test_api): @@ -388,9 +391,7 @@ def increment(): assert_run_success(fprime_test_api, seq) - def test_call_reads_global_transitively_before_definition( - self, fprime_test_api - ): + def test_call_reads_global_transitively_before_definition(self, fprime_test_api): """Calling a function that reads a global only through a function it calls is still an error if the global isn't defined yet.""" seq = """ @@ -407,9 +408,7 @@ def caller(): assert_compile_failure(fprime_test_api, seq) - def test_call_reads_global_transitively_after_definition( - self, fprime_test_api - ): + def test_call_reads_global_transitively_after_definition(self, fprime_test_api): """The transitive case succeeds once the global is declared before the top-level call.""" seq = """ @@ -425,9 +424,7 @@ def caller(): assert_run_success(fprime_test_api, seq) - def test_recursive_func_reads_global_before_definition( - self, fprime_test_api - ): + def test_recursive_func_reads_global_before_definition(self, fprime_test_api): """A recursive function that reads a global, called before that global is declared, is an error.""" seq = """ @@ -443,9 +440,7 @@ def countdown(n: I64): assert_compile_failure(fprime_test_api, seq) - def test_recursive_func_reads_global_after_definition( - self, fprime_test_api - ): + def test_recursive_func_reads_global_after_definition(self, fprime_test_api): """The same recursive function succeeds when the global is declared before the call.""" seq = """ @@ -525,6 +520,7 @@ def test(): assert_run_success(fprime_test_api, seq) + class TestNestedFunctions: def test_func_in_func(self, fprime_test_api): @@ -581,6 +577,7 @@ def fun(): assert_compile_failure(fprime_test_api, seq) + class TestDefaultArguments: def test_default_arg_simple(self, fprime_test_api): @@ -740,9 +737,9 @@ def test(a: U64 = helper()) -> U64: def test_default_arg_forward_called_function(self, fprime_test_api): """Default arg const values are calculated even for forward-called functions. - This tests that when a function is called before it's defined in the source, - the default argument's const value is still properly available. - """ + This tests that when a function is called before it's defined in the source, + the default argument's const value is still properly available. + """ seq = """ def caller() -> U64: # Call test() before it's defined, using default arg @@ -790,6 +787,7 @@ def test(a: U64 = undefined_var) -> U64: # Should fail: "Unknown value" assert_compile_failure(fprime_test_api, seq) + class TestNamedArguments: def test_named_arg_simple(self, fprime_test_api): diff --git a/test/fpy/test_golden.py b/test/fpy/test_golden.py index 67605f0..17607e2 100644 --- a/test/fpy/test_golden.py +++ b/test/fpy/test_golden.py @@ -17,13 +17,10 @@ from fpy.state import get_base_compile_state from fpy.bytecode.assembler import fpybc_directives_to_fpyasm - GOLDEN_DIR = Path(__file__).parent / "golden" # Path to the test dictionary -DEFAULT_DICTIONARY = str( - Path(__file__).parent / "RefTopologyDictionary.json" -) +DEFAULT_DICTIONARY = str(Path(__file__).parent / "RefTopologyDictionary.json") def compile_to_fpybc(source: str) -> str: @@ -31,7 +28,7 @@ def compile_to_fpybc(source: str) -> str: fpy.error.file_name = "" fpy.error.input_text = source fpy.error.input_lines = source.splitlines() - + state = get_base_compile_state(DEFAULT_DICTIONARY) body = text_to_ast(source) @@ -46,7 +43,7 @@ def get_golden_test_cases(): """Find all golden test cases (pairs of .fpy and .fpybc files).""" if not GOLDEN_DIR.exists(): return [] - + test_cases = [] for fpy_file in sorted(GOLDEN_DIR.glob("*.fpy")): fpybc_file = fpy_file.with_suffix(".fpybc") @@ -62,12 +59,12 @@ def test_golden(test_name: str): """ fpy_file = GOLDEN_DIR / f"{test_name}.fpy" fpybc_file = GOLDEN_DIR / f"{test_name}.fpybc" - + source = fpy_file.read_text() expected = fpybc_file.read_text() - + actual = compile_to_fpybc(source) - + assert actual == expected, ( f"Golden test '{test_name}' failed.\n" f"Expected:\n{expected}\n" @@ -81,10 +78,10 @@ def update_golden(test_name: str): """ fpy_file = GOLDEN_DIR / f"{test_name}.fpy" fpybc_file = GOLDEN_DIR / f"{test_name}.fpybc" - + source = fpy_file.read_text() actual = compile_to_fpybc(source) - + fpybc_file.write_text(actual) print(f"Updated {fpybc_file}") diff --git a/test/fpy/test_imports.py b/test/fpy/test_imports.py index 4bc331b..7fe94ce 100644 --- a/test/fpy/test_imports.py +++ b/test/fpy/test_imports.py @@ -34,6 +34,13 @@ importing sequence). * Importing a file that declares sequence arguments (`sequence(x: U32)`) is a hard error. + * The module namespace obeys name groups: it exists only in the name groups + of the symbols the module defines. So `import lib` of a functions-only + module collides with a local function `lib` (callable name group) but + coexists with a local variable `lib` (value name group). + * Importing the same module path more than once in one file is a hard + error. The rule is per-file: a file and a module it imports may each + import the same module. Every sequence that is expected to compile is also *run* (via `assert_run_success`), so the asserts embedded in the sequences actually @@ -42,6 +49,7 @@ implemented; they define the target behavior. Remove the `pytestmark` once `import` is implemented. """ + from pathlib import Path import pytest @@ -239,7 +247,9 @@ def test_missing_module_is_an_error(self, fprime_test_api, tmp_path): main = """\ import does_not_exist """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_no_arg_sequence_is_importable(self, fprime_test_api, tmp_path): """A bare `sequence()` with no arguments is importable -- only @@ -280,7 +290,9 @@ def f( -> main = """\ import broken """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_import_path_is_a_directory_fails(self, fprime_test_api, tmp_path): """If the resolved `.fpy` is a directory, importing fails cleanly @@ -289,7 +301,9 @@ def test_import_path_is_a_directory_fails(self, fprime_test_api, tmp_path): main = """\ import adir """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_empty_module_compiles_without_warning(self, fprime_test_api, tmp_path): """An empty module has no definitions and no side effects.""" @@ -322,7 +336,9 @@ def add_one(x: U32) -> U32: y: U32 = add_one(1) """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_module_name_not_usable_as_value(self, fprime_test_api, tmp_path): """The module name is a namespace, not an expression.""" @@ -339,7 +355,9 @@ def add_one(x: U32) -> U32: y: U32 = lib """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_same_function_name_in_two_modules_no_collision( self, fprime_test_api, tmp_path @@ -390,13 +408,19 @@ def uses_outside() -> U32: main_global: U32 = 5 x: U32 = iso.uses_outside() """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) class TestImportNameCollisions: - """The module name must not clash with an existing top-level name.""" + """The module namespace collides with existing top-level names per name + group: the namespace exists only in the name groups of the symbols the + module defines.""" def test_import_collides_with_local_function(self, fprime_test_api, tmp_path): + """A functions-only module occupies the callable name group, so a + local function with the module's name collides.""" _write_module( tmp_path, "dup", @@ -411,9 +435,33 @@ def f() -> U32: def dup() -> U32: return 2 """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_import_collides_with_local_variable(self, fprime_test_api, tmp_path): + """A module with a top-level variable occupies the value name group, + so a local variable with the module's name collides.""" + _write_module( + tmp_path, + "dup", + """\ +v: U32 = 1 +""", + ) + main = """\ +import dup + +dup: U32 = 3 +""" + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) + + def test_import_coexists_with_local_variable(self, fprime_test_api, tmp_path): + """A functions-only module does NOT occupy the value name group, so a + local variable with the module's name is legal, and both remain + usable in their respective name groups.""" _write_module( tmp_path, "dup", @@ -426,14 +474,19 @@ def f() -> U32: import dup dup: U32 = 3 +x: U32 = dup.f() +assert x == 1 +assert dup == 3 """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) class TestImportDuplicates: - """Importing the same module twice inlines it once.""" + """Importing the same module path twice in one file is an error; the rule + is per-file, so a file and a module it imports may each import the same + module.""" - def test_duplicate_import_is_idempotent(self, fprime_test_api, tmp_path): + def test_duplicate_import_is_error(self, fprime_test_api, tmp_path): _write_module( tmp_path, "lib", @@ -445,11 +498,32 @@ def add_one(x: U32) -> U32: main = """\ import lib import lib +""" + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) + + def test_duplicate_across_files_is_allowed(self, fprime_test_api, tmp_path): + """main imports both `a` and `c`; `a` also imports `c` internally. + Each file imports `c` only once, so no duplicate error.""" + _write_module(tmp_path, "c", "def g() -> U32:\n return 7\n") + _write_module( + tmp_path, + "a", + """\ +import c -y: U32 = lib.add_one(41) -assert y == 42 +def f() -> U32: + return c.g() +""", + ) + main = """\ +import a +import c + +x: U32 = a.f() + c.g() +assert x == 14 """ - # Must not raise a duplicate-definition error from inlining twice. assert_run_success(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) @@ -471,7 +545,9 @@ def f() -> U32: if 1 == 1: import lib """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_import_inside_function_fails(self, fprime_test_api, tmp_path): _write_module( @@ -487,7 +563,9 @@ def wrapper() -> U32: import lib return 1 """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) class TestImportTransitive: @@ -546,7 +624,9 @@ def f() -> U32: x: U32 = b.g() """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) class TestImportCycles: @@ -567,7 +647,10 @@ def f() -> U32: import selfmod """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] + fprime_test_api, + main, + match="(?i)(circular|cycle)", + import_search_dirs=[str(tmp_path)], ) def test_mutual_import_is_cycle_error(self, fprime_test_api, tmp_path): @@ -595,7 +678,10 @@ def b() -> U32: import mod_a """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] + fprime_test_api, + main, + match="(?i)(circular|cycle)", + import_search_dirs=[str(tmp_path)], ) def test_three_way_cycle_error(self, fprime_test_api, tmp_path): @@ -606,7 +692,10 @@ def test_three_way_cycle_error(self, fprime_test_api, tmp_path): import c1 """ assert_compile_failure( - fprime_test_api, main, match="(?i)(circular|cycle)", import_search_dirs=[str(tmp_path)] + fprime_test_api, + main, + match="(?i)(circular|cycle)", + import_search_dirs=[str(tmp_path)], ) @@ -667,11 +756,11 @@ def f() -> U32: y: U32 = mod.f() """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) - def test_missing_leaf_in_existing_package_is_error( - self, fprime_test_api, tmp_path - ): + def test_missing_leaf_in_existing_package_is_error(self, fprime_test_api, tmp_path): """The package dir exists but the leaf module file does not.""" _write_module( tmp_path, @@ -684,11 +773,11 @@ def f() -> U32: main = """\ import pkg.missing """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) - def test_two_modules_in_same_package_no_collision( - self, fprime_test_api, tmp_path - ): + def test_two_modules_in_same_package_no_collision(self, fprime_test_api, tmp_path): """Sibling modules under one package are independently namespaced.""" _write_module(tmp_path, "pkg.a", "def f() -> U32:\n return 1\n") _write_module(tmp_path, "pkg.b", "def f() -> U32:\n return 2\n") @@ -745,7 +834,9 @@ def test_bare_package_import_is_error(self, fprime_test_api, tmp_path): main = """\ import pkg """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) def test_dotted_leaf_package_import_is_error(self, fprime_test_api, tmp_path): """`import a.b` where `a/b/` is a directory but `a/b.fpy` does not exist @@ -754,7 +845,9 @@ def test_dotted_leaf_package_import_is_error(self, fprime_test_api, tmp_path): main = """\ import a.b """ - assert_compile_failure(fprime_test_api, main, import_search_dirs=[str(tmp_path)]) + assert_compile_failure( + fprime_test_api, main, import_search_dirs=[str(tmp_path)] + ) class TestImportSearchDirs: @@ -773,9 +866,7 @@ def test_module_found_in_later_search_dir(self, fprime_test_api, tmp_path): x: U32 = lib.f() assert x == 9 """ - assert_run_success( - fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] - ) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(d1), str(d2)]) def test_first_search_dir_shadows_later(self, fprime_test_api, tmp_path): """When a module name exists in two search dirs, the earlier dir wins.""" @@ -791,9 +882,7 @@ def test_first_search_dir_shadows_later(self, fprime_test_api, tmp_path): x: U32 = lib.f() assert x == 1 """ - assert_run_success( - fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] - ) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(d1), str(d2)]) def test_search_order_respects_dir_order(self, fprime_test_api, tmp_path): """Reversing the search-dir order flips which module wins.""" @@ -809,13 +898,9 @@ def test_search_order_respects_dir_order(self, fprime_test_api, tmp_path): x: U32 = lib.f() assert x == 2 """ - assert_run_success( - fprime_test_api, main, import_search_dirs=[str(d2), str(d1)] - ) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(d2), str(d1)]) - def test_dotted_module_resolved_across_search_dirs( - self, fprime_test_api, tmp_path - ): + def test_dotted_module_resolved_across_search_dirs(self, fprime_test_api, tmp_path): """Dotted resolution honors the search path: `pkg/mod.fpy` lives only in the second dir.""" d1 = tmp_path / "d1" @@ -829,9 +914,7 @@ def test_dotted_module_resolved_across_search_dirs( x: U32 = pkg.mod.f() assert x == 5 """ - assert_run_success( - fprime_test_api, main, import_search_dirs=[str(d1), str(d2)] - ) + assert_run_success(fprime_test_api, main, import_search_dirs=[str(d1), str(d2)]) def test_no_search_dirs_cannot_resolve(self, fprime_test_api, tmp_path): """With an empty search path, no import can resolve.""" diff --git a/test/fpy/test_integration.py b/test/fpy/test_integration.py index a54efd5..2ff65bf 100644 --- a/test/fpy/test_integration.py +++ b/test/fpy/test_integration.py @@ -213,7 +213,7 @@ def recurse(limit: U64): fprime_test_api, seq, {"CdhCore.cmdDisp.CommandsDispatched": FpyValue(U32, 45).serialize()}, - timeout_s=20 + timeout_s=20, ) def test_readme_bare_cmd_fail_exits(self, fprime_test_api): @@ -223,5 +223,7 @@ def test_readme_bare_cmd_fail_exits(self, fprime_test_api): # sequence exits with an error """ assert_run_failure( - fprime_test_api, seq, DirectiveErrorCode.CMD_FAIL, + fprime_test_api, + seq, + DirectiveErrorCode.CMD_FAIL, ) diff --git a/test/fpy/test_logging.py b/test/fpy/test_logging.py index 0cd949c..120d81e 100644 --- a/test/fpy/test_logging.py +++ b/test/fpy/test_logging.py @@ -10,22 +10,25 @@ class TestLog: def test_default_severity(self, fprime_test_api): - seq = ''' + seq = """ log("hello world") -''' +""" assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("config.getoption('--use-gds')", reason="FATAL severity kills the program on the GDS") + @pytest.mark.skipif( + "config.getoption('--use-gds')", + reason="FATAL severity kills the program on the GDS", + ) def test_explicit_severity(self, fprime_test_api): - seq = ''' + seq = """ log("oh no", Fw.LogSeverity.FATAL) -''' +""" assert_run_success(fprime_test_api, seq) def test_default_severity_is_activity_hi(self, fprime_test_api): - seq = ''' + seq = """ log("test message") -''' +""" _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 @@ -34,9 +37,9 @@ def test_default_severity_is_activity_hi(self, fprime_test_api): assert push_vals[-2].val == b"test message" def test_explicit_fatal(self, fprime_test_api): - seq = ''' + seq = """ log("critical", Fw.LogSeverity.FATAL) -''' +""" _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 @@ -45,9 +48,9 @@ def test_explicit_fatal(self, fprime_test_api): assert push_vals[-2].val == b"critical" def test_explicit_warning_hi(self, fprime_test_api): - seq = ''' + seq = """ log("watch out", Fw.LogSeverity.WARNING_HI) -''' +""" _, directives, _ = compile_seq(seq) push_vals = [d for d in directives if isinstance(d, PushValDirective)] assert len(push_vals) >= 3 @@ -55,9 +58,9 @@ def test_explicit_warning_hi(self, fprime_test_api): assert push_vals[-3].val == bytes([2]) def test_emits_pop_event_directive(self, fprime_test_api): - seq = ''' + seq = """ log("test") -''' +""" _, directives, _ = compile_seq(seq) pop_dirs = [d for d in directives if isinstance(d, PopEventDirective)] assert len(pop_dirs) == 1 @@ -67,9 +70,9 @@ def test_emits_pop_event_directive(self, fprime_test_api): assert push_vals[-1].val == len(b"test").to_bytes(4, "big") def test_serialization_roundtrip(self, fprime_test_api): - seq = ''' + seq = """ log("roundtrip test") -''' +""" _, directives, _ = compile_seq(seq) pop_dirs = [d for d in directives if isinstance(d, PopEventDirective)] assert len(pop_dirs) == 1 @@ -80,7 +83,7 @@ def test_serialization_roundtrip(self, fprime_test_api): assert isinstance(deserialized, PopEventDirective) def test_multiple_events(self, fprime_test_api): - seq = ''' + seq = """ log("test") log("test", Fw.LogSeverity.DIAGNOSTIC) log("test", Fw.LogSeverity.COMMAND) @@ -88,18 +91,18 @@ def test_multiple_events(self, fprime_test_api): log("test", Fw.LogSeverity.ACTIVITY_LO) log("test", Fw.LogSeverity.WARNING_HI) log("test", Fw.LogSeverity.WARNING_LO) -''' +""" assert_run_success(fprime_test_api, seq) def test_non_literal_message_rejected(self, fprime_test_api): - seq = ''' + seq = """ x: U32 = 42 log(x) -''' +""" assert_compile_failure(fprime_test_api, seq) def test_empty_string(self, fprime_test_api): - seq = ''' + seq = """ log("") -''' +""" assert_run_success(fprime_test_api, seq) diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index 9289729..40be627 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -40,7 +40,9 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + fpy_main, + "analysis_to_fpybc_directives", + lambda body, state: (["directive"], []), ) monkeypatch.setattr( fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) @@ -59,7 +61,9 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): assert captured_kwargs["ground_binary_dir"] == str(bin_dir.resolve()) -def test_compile_main_ground_binary_dir_defaults_to_input_parent(monkeypatch, tmp_path, capsys): +def test_compile_main_ground_binary_dir_defaults_to_input_parent( + monkeypatch, tmp_path, capsys +): """When --ground-binary-dir is not passed, it defaults to the input file's parent.""" input_path = tmp_path / "seq.fpy" input_path.write_text("content") @@ -77,7 +81,9 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + fpy_main, + "analysis_to_fpybc_directives", + lambda body, state: (["directive"], []), ) monkeypatch.setattr( fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) @@ -108,7 +114,9 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + fpy_main, + "analysis_to_fpybc_directives", + lambda body, state: (["directive"], []), ) monkeypatch.setattr( fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) @@ -196,7 +204,9 @@ def test_compile_main_fpyasm_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_error, "debug", False, raising=False) monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -208,7 +218,9 @@ def fake_analysis_to_fpybc_directives(body, state): monkeypatch.setattr( fpy_main, "analysis_to_fpybc_directives", fake_analysis_to_fpybc_directives ) - monkeypatch.setattr(fpy_main, "fpybc_directives_to_fpyasm", lambda directives: "FPYASM") + monkeypatch.setattr( + fpy_main, "fpybc_directives_to_fpyasm", lambda directives: "FPYASM" + ) def fail_serialize(*args): raise AssertionError("serialize_directives should not be called") @@ -240,7 +252,9 @@ def test_compile_main_wat_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -273,7 +287,9 @@ def test_compile_main_binary_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( @@ -416,11 +432,13 @@ def fake_text_to_ast(text): monkeypatch.setattr(fpy_main, "text_to_ast", fake_text_to_ast) monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) - directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xAB\xCD") + directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xab\xcd") monkeypatch.setattr( fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([directive], []) @@ -435,14 +453,17 @@ def fake_send(cmd_opcode, args, zmq_addr): monkeypatch.setattr(fpy_main, "send_command_zmq", fake_send) - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, 42)', - "-d", "dict.json", - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, 42)', + "-d", + "dict.json", + ] + ) assert captured_source["text"] == 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, 42)\n' assert sent["cmd_opcode"] == 0x10006001 - assert sent["args"] == b"\xAB\xCD" + assert sent["args"] == b"\xab\xcd" assert "Sending" in capsys.readouterr().out @@ -450,7 +471,9 @@ def test_cmd_main_compile_error(monkeypatch, capsys): """Exit 1 when the compiler raises an error.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) @@ -460,10 +483,13 @@ def raise_compile_error(body, state): monkeypatch.setattr(fpy_main, "analysis_to_fpybc_directives", raise_compile_error) with pytest.raises(SystemExit) as exc: - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, bad_value)', - "-d", "dict.json", - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, bad_value)', + "-d", + "dict.json", + ] + ) assert exc.value.code == 1 @@ -474,19 +500,25 @@ def test_cmd_main_non_const_arg(monkeypatch, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", + fpy_main, + "analysis_to_fpybc_directives", lambda body, state: ([StackCmdDirective(args_size=10)], []), ) with pytest.raises(SystemExit) as exc: - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, some_tlm)', - "-d", "dict.json", - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT, some_tlm)', + "-d", + "dict.json", + ] + ) assert exc.value.code == 1 assert "Command arguments must be constant expressions" in capsys.readouterr().err @@ -496,13 +528,16 @@ def test_cmd_main_send_failure(monkeypatch, capsys): """Exit 1 when the ZMQ send raises an exception.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", + fpy_main, + "analysis_to_fpybc_directives", lambda body, state: ([directive], []), ) @@ -512,10 +547,13 @@ def fail_send(*a): monkeypatch.setattr(fpy_main, "send_command_zmq", fail_send) with pytest.raises(SystemExit) as exc: - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', - "-d", "dict.json", - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + ] + ) assert exc.value.code == 1 assert "Failed to send command" in capsys.readouterr().err @@ -534,7 +572,8 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", + fpy_main, + "analysis_to_fpybc_directives", lambda body, state: ([ConstCmdDirective(cmd_opcode=0x10006001, args=b"")], []), ) monkeypatch.setattr(fpy_main, "send_command_zmq", lambda *a: None) @@ -542,11 +581,15 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None, **kwargs): bin_dir = tmp_path / "bins" bin_dir.mkdir() - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', - "-d", "dict.json", - "-g", str(bin_dir), - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "-g", + str(bin_dir), + ] + ) assert captured_kwargs["ground_binary_dir"] == str(bin_dir.resolve()) @@ -555,24 +598,33 @@ def test_cmd_main_zmq_addr(monkeypatch, capsys): """--zmq-addr is passed through to send_command_zmq.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "analysis_to_fpybc_directives", + fpy_main, + "analysis_to_fpybc_directives", lambda body, state: ([directive], []), ) sent = {} - monkeypatch.setattr(fpy_main, "send_command_zmq", lambda o, a, addr: sent.update(addr=addr)) + monkeypatch.setattr( + fpy_main, "send_command_zmq", lambda o, a, addr: sent.update(addr=addr) + ) - fpy_main.cmd_main([ - 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', - "-d", "dict.json", - "--zmq-addr", "tcp://192.168.1.1:50050", - ]) + fpy_main.cmd_main( + [ + 'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)', + "-d", + "dict.json", + "--zmq-addr", + "tcp://192.168.1.1:50050", + ] + ) assert sent["addr"] == "tcp://192.168.1.1:50050" @@ -642,7 +694,9 @@ def test_depend_main_compile_error_exits(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) def raise_compile_error(_body, _state): @@ -664,7 +718,9 @@ def test_depend_main_outputs_deps(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") monkeypatch.setattr( - fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None, **kwargs: "STATE" + fpy_main, + "get_base_compile_state", + lambda dictionary, ground_binary_dir=None, **kwargs: "STATE", ) monkeypatch.setattr( fpy_main, @@ -689,4 +745,6 @@ def test_build_command_packet(): 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_size + expected_descriptor + expected_opcode + expected_args + ) diff --git a/test/fpy/test_model.py b/test/fpy/test_model.py index 0cdec24..8770c86 100644 --- a/test/fpy/test_model.py +++ b/test/fpy/test_model.py @@ -202,6 +202,7 @@ def test_store_abs_const_offset_underflow(self): result = model.dispatch(StoreAbsConstOffsetDirective(0, 8)) assert result == DirectiveErrorCode.STACK_UNDERFLOW + class TestPushDirectives: """Tests for push directive error conditions.""" @@ -272,6 +273,7 @@ def test_set_seed_stack_underflow(self): result = model.dispatch(SetSeedDirective()) assert result == DirectiveErrorCode.STACK_ACCESS_OUT_OF_BOUNDS + class TestWaitDirectives: """Tests for wait directive error conditions.""" @@ -622,7 +624,7 @@ def test_signed_div_min_by_neg_one(self): model.push(-1) result = model.dispatch(SignedIntDivideDirective()) assert result == DirectiveErrorCode.NO_ERROR or result is None - ret = model.pop() + ret = model.pop() assert ret == MIN_INT64 def test_float_add_stack_underflow(self): @@ -896,7 +898,7 @@ def test_fdiv_positive_by_neg_zero(self): result = model.dispatch(FloatDivideDirective()) assert result == DirectiveErrorCode.NO_ERROR val = model.pop(type=float) - assert val == float('-inf') + assert val == float("-inf") def test_fdiv_negative_by_neg_zero(self): """(-1.0) / (-0.0) should be +inf.""" @@ -906,7 +908,7 @@ def test_fdiv_negative_by_neg_zero(self): result = model.dispatch(FloatDivideDirective()) assert result == DirectiveErrorCode.NO_ERROR val = model.pop(type=float) - assert val == float('inf') + assert val == float("inf") def test_fdiv_positive_by_pos_zero(self): """1.0 / 0.0 should be +inf.""" @@ -916,7 +918,7 @@ def test_fdiv_positive_by_pos_zero(self): result = model.dispatch(FloatDivideDirective()) assert result == DirectiveErrorCode.NO_ERROR val = model.pop(type=float) - assert val == float('inf') + assert val == float("inf") def test_fdiv_zero_by_zero(self): """0.0 / 0.0 should be NaN.""" @@ -1038,7 +1040,9 @@ def test_multiple_arg_types_size_mismatch(self): """Size mismatch with multiple arg types should report correct totals.""" model = FpySequencerModel() # U8(1) + U32(4) + F64(8) = 13 bytes expected - with pytest.raises(ValidationError, match="3 arg.*totalling 13 bytes.*got 10 bytes"): + with pytest.raises( + ValidationError, match="3 arg.*totalling 13 bytes.*got 10 bytes" + ): model.run( [NoOpDirective()], arg_types=[U8, U32, F64], diff --git a/test/fpy/test_parsing.py b/test/fpy/test_parsing.py index 874676f..a37b15a 100644 --- a/test/fpy/test_parsing.py +++ b/test/fpy/test_parsing.py @@ -69,6 +69,7 @@ def test_newline_in_body(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestLiterals: def test_int_literal(self, fprime_test_api): @@ -133,6 +134,7 @@ def test_hex_literal_underscore(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestExpressionStatements: def test_int_as_stmt(self, fprime_test_api): diff --git a/test/fpy/test_rng.py b/test/fpy/test_rng.py index 7edb72b..329e867 100644 --- a/test/fpy/test_rng.py +++ b/test/fpy/test_rng.py @@ -20,7 +20,9 @@ def test_randf(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("config.getoption('--use-gds')", reason="Python model PRNG expectation only") + @pytest.mark.skipif( + "config.getoption('--use-gds')", reason="Python model PRNG expectation only" + ) def test_rand_seeded_sequence(self, fprime_test_api): seq = """ set_seed(123456789) @@ -31,7 +33,10 @@ def test_rand_seeded_sequence(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("not config.getoption('--use-gds')", reason="requires live GDS C++ PRNG implementation") + @pytest.mark.skipif( + "not config.getoption('--use-gds')", + reason="requires live GDS C++ PRNG implementation", + ) def test_rand_seeded_sequence_gds(self, fprime_test_api): seq = """ set_seed(123456789) @@ -42,7 +47,9 @@ def test_rand_seeded_sequence_gds(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("config.getoption('--use-gds')", reason="Python model PRNG expectation only") + @pytest.mark.skipif( + "config.getoption('--use-gds')", reason="Python model PRNG expectation only" + ) def test_randf_seeded_sequence(self, fprime_test_api): seq = """ set_seed(123456789) @@ -53,7 +60,10 @@ def test_randf_seeded_sequence(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("not config.getoption('--use-gds')", reason="requires live GDS C++ PRNG implementation") + @pytest.mark.skipif( + "not config.getoption('--use-gds')", + reason="requires live GDS C++ PRNG implementation", + ) def test_randf_seeded_sequence_gds(self, fprime_test_api): seq = """ set_seed(123456789) @@ -64,14 +74,20 @@ def test_randf_seeded_sequence_gds(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) - @pytest.mark.skipif("config.getoption('--use-gds')", reason="Python model initial_time_us expectation only") + @pytest.mark.skipif( + "config.getoption('--use-gds')", + reason="Python model initial_time_us expectation only", + ) def test_rand_uses_time_as_initial_seed(self, fprime_test_api): seq = """ assert rand() == 1309080412 """ assert_run_success(fprime_test_api, seq, initial_time_us=5_000_000) - @pytest.mark.skipif("config.getoption('--use-gds')", reason="Python model initial_time_us expectation only") + @pytest.mark.skipif( + "config.getoption('--use-gds')", + reason="Python model initial_time_us expectation only", + ) def test_set_seed_overrides_time_initialized_rng(self, fprime_test_api): seq = """ ignored: U32 = rand() @@ -80,7 +96,10 @@ def test_set_seed_overrides_time_initialized_rng(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq, initial_time_us=5_000_000) - @pytest.mark.skipif("not config.getoption('--use-gds')", reason="requires live GDS C++ PRNG implementation") + @pytest.mark.skipif( + "not config.getoption('--use-gds')", + reason="requires live GDS C++ PRNG implementation", + ) def test_set_seed_overrides_time_initialized_rng_gds(self, fprime_test_api): seq = """ ignored: U32 = rand() diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 53b635e..e65d8c7 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -7,6 +7,7 @@ against a live GDS deployment (``--use-gds``). When the fixture is ``None`` (the default) the Python sequencer model is used instead. """ + import json import tempfile from pathlib import Path @@ -223,7 +224,12 @@ def test_wrong_value_causes_failure(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, 1) """ - assert_run_failure(fprime_test_api, parent_seq, error_code=DirectiveErrorCode.CMD_FAIL, ground_binary_dir=tmpdir) + assert_run_failure( + fprime_test_api, + parent_seq, + error_code=DirectiveErrorCode.CMD_FAIL, + ground_binary_dir=tmpdir, + ) class TestSeqCallingErrors: @@ -242,7 +248,12 @@ def test_wrong_arg_count(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, 42) """ - assert_compile_failure(fprime_test_api, parent_seq, match="Missing required argument 'y'", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, + parent_seq, + match="Missing required argument 'y'", + ground_binary_dir=tmpdir, + ) def test_wrong_arg_type(self, fprime_test_api): """Providing incompatible vararg types should fail at compile time.""" @@ -257,7 +268,9 @@ def test_wrong_arg_type(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, true) """ - assert_compile_failure(fprime_test_api, parent_seq, ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, parent_seq, ground_binary_dir=tmpdir + ) def test_missing_bin_file(self, fprime_test_api): """Calling a nonexistent .bin file should fail at compile time.""" @@ -266,7 +279,9 @@ def test_missing_bin_file(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{fake_path}", Svc.BlockState.BLOCK) """ - assert_compile_failure(fprime_test_api, parent_seq, match="not found", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, parent_seq, match="not found", ground_binary_dir=tmpdir + ) def test_no_binary_dir(self, fprime_test_api): """Calling without binary_dir should fail at compile time.""" @@ -281,7 +296,9 @@ def test_no_binary_dir(self, fprime_test_api): Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK) """ # No binary_dir passed - assert_compile_failure(fprime_test_api, parent_seq, match="binary directory") + assert_compile_failure( + fprime_test_api, parent_seq, match="binary directory" + ) class TestSeqCallingNested: @@ -552,7 +569,12 @@ def test_unknown_named_arg(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, z=42) """ - assert_compile_failure(fprime_test_api, parent_seq, match="Unknown argument 'z'", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, + parent_seq, + match="Unknown argument 'z'", + ground_binary_dir=tmpdir, + ) def test_duplicate_named_arg(self, fprime_test_api): """Same named arg specified twice should fail.""" @@ -569,7 +591,12 @@ def test_duplicate_named_arg(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, x=1, x=2) """ - assert_compile_failure(fprime_test_api, parent_seq, match="specified multiple times", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, + parent_seq, + match="specified multiple times", + ground_binary_dir=tmpdir, + ) def test_positional_and_named_conflict(self, fprime_test_api): """Same arg specified both by position and by name should fail.""" @@ -586,7 +613,12 @@ def test_positional_and_named_conflict(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, 42, x=99) """ - assert_compile_failure(fprime_test_api, parent_seq, match="specified multiple times", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, + parent_seq, + match="specified multiple times", + ground_binary_dir=tmpdir, + ) def test_missing_named_arg(self, fprime_test_api): """Missing a required arg when using named args should fail.""" @@ -603,7 +635,12 @@ def test_missing_named_arg(self, fprime_test_api): parent_seq = f"""\ Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, x=42) """ - assert_compile_failure(fprime_test_api, parent_seq, match="Missing required argument 'y'", ground_binary_dir=tmpdir) + assert_compile_failure( + fprime_test_api, + parent_seq, + match="Missing required argument 'y'", + ground_binary_dir=tmpdir, + ) class TestSeqArgLimits: @@ -713,14 +750,15 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): Path(child_path).write_bytes(data) args = ", ".join("0" for _ in range(10)) - parent_seq = f'Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, {args})\n' + parent_seq = ( + f'Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, {args})\n' + ) state = get_base_compile_state(dict_path, tmpdir) body = text_to_ast(parent_seq) assert body is not None with pytest.raises(fpy.error.CompileError) as exc_info: state = analyze_ast(body, state) analysis_to_fpybc_directives(body, state) - assert "exceed" in str(exc_info.value) and "64 bytes" in str(exc_info.value), ( - f"Diagnostic should mention the 64-byte capacity, got: {exc_info.value}" - ) - + assert "exceed" in str(exc_info.value) and "64 bytes" in str( + exc_info.value + ), f"Diagnostic should mention the 64-byte capacity, got: {exc_info.value}" diff --git a/test/fpy/test_sequence_metadata.py b/test/fpy/test_sequence_metadata.py index b1f849b..0080595 100644 --- a/test/fpy/test_sequence_metadata.py +++ b/test/fpy/test_sequence_metadata.py @@ -223,6 +223,7 @@ def test_sequence_parameter_in_loops(fprime_test_api): """ assert_compile_success(fprime_test_api, seq) + def test_sequence_literal_as_type(fprime_test_api): """Test that a literal as a type annotation fails.""" seq = """ @@ -314,6 +315,7 @@ def test_defining_sequence_in_loop(fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + def test_defining_sequence_in_if_stmt(fprime_test_api): """Test defining sequence in an if stmt.""" seq = """ @@ -326,6 +328,7 @@ def test_defining_sequence_in_if_stmt(fprime_test_api): # ---- End-to-end arg passing tests ---- + def test_run_sequence_with_u32_arg(fprime_test_api): """Run a sequence that takes a U32 arg and uses it.""" seq = """ @@ -342,7 +345,8 @@ def test_run_sequence_with_multiple_args(fprime_test_api): sum: U64 = a + b """ assert_run_success( - fprime_test_api, seq, + fprime_test_api, + seq, args=[FpyValue(U32, 10), FpyValue(U32, 32)], ) @@ -361,7 +365,9 @@ def test_run_sequence_args_wrong_size(fprime_test_api): seq = """ sequence(x: U32) """ - assert_run_failure(fprime_test_api, seq, validation_error=True, args=[FpyValue(U8, 0)]) + assert_run_failure( + fprime_test_api, seq, validation_error=True, args=[FpyValue(U8, 0)] + ) def test_run_sequence_args_expected_but_missing(fprime_test_api): @@ -408,7 +414,9 @@ def test_arg_value_arithmetic(fprime_test_api): sequence(a: U32, b: U32) assert a + b == 100 """ - assert_run_success(fprime_test_api, seq, args=[FpyValue(U32, 60), FpyValue(U32, 40)]) + assert_run_success( + fprime_test_api, seq, args=[FpyValue(U32, 60), FpyValue(U32, 40)] + ) def test_arg_value_in_if(fprime_test_api): @@ -430,7 +438,8 @@ def test_arg_wrong_value_fails_assert(fprime_test_api): assert x == 99 """ assert_run_failure( - fprime_test_api, seq, + fprime_test_api, + seq, error_code=DirectiveErrorCode.EXIT_WITH_ERROR, args=[FpyValue(U32, 1)], ) @@ -445,7 +454,8 @@ def test_multiple_args_correct_offsets(fprime_test_api): assert c == 3 """ assert_run_success( - fprime_test_api, seq, + fprime_test_api, + seq, args=[FpyValue(U32, 1), FpyValue(U32, 2), FpyValue(U32, 3)], ) @@ -488,7 +498,8 @@ def add(a: U32, b: U32) -> U64: assert y == 20 """ assert_run_success( - fprime_test_api, seq, + fprime_test_api, + seq, args=[FpyValue(U32, 10), FpyValue(U32, 20)], ) @@ -519,7 +530,8 @@ def test_arg_with_flags_and_locals(fprime_test_api): flags.assert_cmd_success = True """ assert_run_success( - fprime_test_api, seq, + fprime_test_api, + seq, args=[FpyValue(U32, 3), FpyValue(U32, 4)], ) @@ -563,7 +575,8 @@ def test_modify_arg_does_not_affect_other_args(fprime_test_api): assert b == 2 """ assert_run_success( - fprime_test_api, seq, + fprime_test_api, + seq, args=[FpyValue(U32, 1), FpyValue(U32, 2)], ) diff --git a/test/fpy/test_telemetry.py b/test/fpy/test_telemetry.py index c9f2e34..2629f0a 100644 --- a/test/fpy/test_telemetry.py +++ b/test/fpy/test_telemetry.py @@ -36,8 +36,9 @@ def test_get_struct_member_of_tlm(self, fprime_test_api): fprime_test_api, seq, { - "Ref.typeDemo.ChoicePairCh": FpyValue(lookup_type(fprime_test_api, "Ref.ChoicePair"), - {"firstChoice": "ONE", "secondChoice": "ONE"} + "Ref.typeDemo.ChoicePairCh": FpyValue( + lookup_type(fprime_test_api, "Ref.ChoicePair"), + {"firstChoice": "ONE", "secondChoice": "ONE"}, ).serialize() }, ) diff --git a/test/fpy/test_time_seqs.py b/test/fpy/test_time_seqs.py index 40c1781..8f5a7cc 100644 --- a/test/fpy/test_time_seqs.py +++ b/test/fpy/test_time_seqs.py @@ -17,7 +17,6 @@ assert_run_success, ) - # ==================== time_cmp Tests ==================== @@ -266,7 +265,7 @@ def test_time_sub_large_difference(self, fprime_test_api): def test_time_sub_u32_overflow_case(self, fprime_test_api): """Test time_sub correctly handles values that would overflow U32 when converted to microseconds. - + If the seconds * 1_000_000 calculation happened in U32, values above 4294 seconds would overflow. This test verifies the calculation happens in U64. """ @@ -283,7 +282,7 @@ def test_time_sub_u32_overflow_case(self, fprime_test_api): def test_time_sub_max_u32_seconds(self, fprime_test_api): """Test time_sub with max U32 seconds. - + The result of time_sub is always <= the larger input, so it can't overflow U32. This test verifies the max case works correctly. """ @@ -375,7 +374,7 @@ def test_time_add_large_interval(self, fprime_test_api): def test_time_add_u32_overflow_case(self, fprime_test_api): """Test time_add correctly handles values that would overflow U32 when converted to microseconds. - + If the seconds * 1_000_000 calculation happened in U32, values above 4294 seconds would overflow. This test verifies the calculation happens in U64. """ @@ -392,7 +391,7 @@ def test_time_add_u32_overflow_case(self, fprime_test_api): def test_time_add_result_overflow_u32_asserts(self, fprime_test_api): """Test that time_add asserts when the result seconds would overflow U32. - + Adding max U32 seconds to a large interval could produce a result that doesn't fit in U32 seconds. This should assert. """ @@ -581,7 +580,7 @@ def test_time_add_wrong_first_arg(self, fprime_test_api): class TestTimeOperatorOverloading: """Tests for operator overloading on Fw.Time and Fw.TimeIntervalValue types. - + These tests verify that binary operators are properly desugared to function calls: - Time - Time -> time_sub - Time + TimeInterval -> time_add @@ -796,6 +795,7 @@ def test_interval_comparison_in_while_loop(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + # ==================== Time Builtins Tests ==================== # Tests for sleep, sleep_until, now(), time constructors, and simulated time. # Migrated from test_seqs.py. @@ -859,6 +859,7 @@ def test_const_folding_time_eq(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestWait: def test_wait_rel(self, fprime_test_api): @@ -912,7 +913,10 @@ def test_wait_abs_bad_arg(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) -@pytest.mark.skipif("config.getoption('--use-gds')", reason="simulated time is only available in the Python model") +@pytest.mark.skipif( + "config.getoption('--use-gds')", + reason="simulated time is only available in the Python model", +) class TestSimulatedTime: """Tests for simulated time functionality. diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index a0629e9..1ec737b 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -32,6 +32,7 @@ def test_var_with_enum_type(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestStructs: def test_get_const_struct_member(self, fprime_test_api): @@ -183,6 +184,7 @@ def test_write_struct_array_member_nonzero_history(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestArrays: def test_array_ctor_var_arg(self, fprime_test_api): @@ -266,10 +268,10 @@ def test_array_oob_2(self, fprime_test_api): def test_const_array_oob(self, fprime_test_api): """Out-of-bounds on a const array expression (not a variable). - The parent is a type constructor call, so CalculateConstExprValues - has a non-None parent_value. Without the bounds guard there, - this would crash with a Python IndexError instead of a compile error. - """ + The parent is a type constructor call, so CalculateConstExprValues + has a non-None parent_value. Without the bounds guard there, + this would crash with a Python IndexError instead of a compile error. + """ seq = """ val: U32 = Svc.ComQueueDepth(10, 20)[2] """ @@ -367,6 +369,7 @@ def test_write_array_elem_struct_member_var_idx(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestConstFoldEquality: def test_const_fold_struct_eq(self, fprime_test_api): @@ -457,6 +460,7 @@ def test_runtime_enum_equality(self, fprime_test_api): """ assert_run_success(fprime_test_api, seq) + class TestTypeErrors: def test_u8_too_large(self, fprime_test_api): @@ -522,6 +526,7 @@ def test_calling_variable_should_fail_gracefully(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + class TestStringTypes: def test_string_eq(self, fprime_test_api): @@ -536,6 +541,7 @@ def test_string_type(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestConstCasts: def test_signed_int_const_casts(self, fprime_test_api): @@ -625,6 +631,7 @@ def test_unsigned_int_to_float_const_casts(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + class TestRuntimeCasts: def test_signed_int_runtime_casts(self, fprime_test_api): @@ -931,6 +938,7 @@ def test(a: U64, b: F64, c: I32) -> F64: assert_run_success(fprime_test_api, seq) + class TestNonConstSized: def test_non_const_sized_var_decl(self, fprime_test_api): @@ -963,6 +971,7 @@ def test_non_const_sized_ctor_call(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestConstructorDefaults: def test_struct_ctor_all_defaults(self, fprime_test_api): @@ -1020,8 +1029,7 @@ def test_struct_ctor_enum_member_default(self, fprime_test_api): assert_run_success(fprime_test_api, seq) def test_array_elem_non_first_struct_member(self, fprime_test_api): - """Accessing a non-first struct member on an array element must not crash. - """ + """Accessing a non-first struct member on an array element must not crash.""" seq = """ val: Ref.SignalPairSet = Ref.SignalPairSet( \ Ref.SignalPair(1.0, 2.0), \ diff --git a/test/fpy/test_types_visitors.py b/test/fpy/test_types_visitors.py index 70ec114..526996e 100644 --- a/test/fpy/test_types_visitors.py +++ b/test/fpy/test_types_visitors.py @@ -76,6 +76,7 @@ def visit_assign(self, node: AstAssign, state): assert visitor.visited == ["ident:x", "number:7", "assign"] + def test_top_down_visitor_breadth_first_order(): class RecordingTopDownVisitor(TopDownVisitor): def __init__(self): @@ -160,6 +161,7 @@ def visit_var(self, node: AstIdent, state): with pytest.raises(RuntimeError, match="should fail"): visitor.run(node, state) + def test_transformer_replaces_child_nodes(): class ZeroingTransformer(Transformer): def visit_number(self, node: AstNumber, state): diff --git a/test/fpy/test_variables.py b/test/fpy/test_variables.py index 2847803..ada09e9 100644 --- a/test/fpy/test_variables.py +++ b/test/fpy/test_variables.py @@ -58,6 +58,7 @@ def test_var_type_ann_bad(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + class TestAssignment: def test_create_after_assign_var(self, fprime_test_api): @@ -151,6 +152,7 @@ def test_assign_bad_lhs_2(self, fprime_test_api): """ assert_compile_failure(fprime_test_api, seq) + class TestScoping: def test_use_before_declare(self, fprime_test_api): diff --git a/test/fpy/test_warnings.py b/test/fpy/test_warnings.py index f7d7044..e3c8de4 100644 --- a/test/fpy/test_warnings.py +++ b/test/fpy/test_warnings.py @@ -5,6 +5,7 @@ tests drive the machinery through the `empty-range` warning, which is the one warning currently emitted by semantic analysis. """ + import pytest from fpy.test_helpers import CompilationFailed, compile_seq @@ -15,7 +16,6 @@ parse_warning_set, ) - # A sequence whose for-loop range is statically empty (5 >= 3), which triggers # the EMPTY_RANGE warning during semantic analysis. EMPTY_RANGE_SEQ = """\ @@ -63,9 +63,9 @@ class TestWarningEmission: def test_empty_range_warns(self): state, _, _ = compile_seq(EMPTY_RANGE_SEQ) - assert any(w.type == WarningType.EMPTY_RANGE for w in state.warnings), ( - f"expected an empty-range warning, got {state.warnings}" - ) + assert any( + w.type == WarningType.EMPTY_RANGE for w in state.warnings + ), f"expected an empty-range warning, got {state.warnings}" def test_warning_does_not_fail_compile(self): # Should not raise -- warnings are non-fatal by default. @@ -82,9 +82,7 @@ def test_ignore_suppresses_warning(self): assert state.warnings == [] def test_ignore_all_suppresses_warning(self): - state, _, _ = compile_seq( - EMPTY_RANGE_SEQ, ignored_warnings=set(WarningType) - ) + state, _, _ = compile_seq(EMPTY_RANGE_SEQ, ignored_warnings=set(WarningType)) assert state.warnings == [] def test_ignore_unrelated_type_keeps_warning(self): @@ -99,15 +97,11 @@ class TestEscalateWarnings: def test_error_escalates_to_failure(self): with pytest.raises(CompilationFailed): - compile_seq( - EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE} - ) + compile_seq(EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE}) def test_escalated_message_mentions_type(self): with pytest.raises(CompilationFailed, match=r"empty-range"): - compile_seq( - EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE} - ) + compile_seq(EMPTY_RANGE_SEQ, error_warnings={WarningType.EMPTY_RANGE}) def test_unrelated_error_type_does_not_fail(self): # Escalating a different warning type must not affect the empty-range warning. diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 58ef402..a57b0bb 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -28,7 +28,6 @@ from fpy.state import get_base_compile_state from fpy.test_helpers import compile_seq_wasm, default_dictionary, run_seq_wasm - # Every test in this module drives the LLVM/wasm backend end-to-end. The wasm # marker makes conftest build the spacewasm runner on demand, so these always # run on the wasm backend even when --wasm isn't passed. @@ -72,7 +71,7 @@ def test_empty_sequence_succeeds(self): "exit_code, expected", [ (None, EXIT_WITH_ERROR), # no code written -> default - (42, 42), # written code returned verbatim + (42, 42), # written code returned verbatim (123, 123), ], ) @@ -120,39 +119,51 @@ def test_unsigned_widening(self): def test_signed_widening(self): # I32 var read in a wider context -> sign-extend. - assert run_seq_wasm( - "x: I32 = 0 - 5\ny: I64 = x + 1\nassert y == 0 - 4\n" - ) == NO_ERROR + assert ( + run_seq_wasm("x: I32 = 0 - 5\ny: I64 = x + 1\nassert y == 0 - 4\n") + == NO_ERROR + ) def test_float_variable(self): - assert run_seq_wasm("a: F64 = 2.5\nb: F64 = a + 1.5\nassert b == 4.0\n") == NO_ERROR + assert ( + run_seq_wasm("a: F64 = 2.5\nb: F64 = a + 1.5\nassert b == 4.0\n") + == NO_ERROR + ) def test_bool_variable(self): assert run_seq_wasm("ok: bool = True\nassert ok\n") == NO_ERROR assert run_seq_wasm("ok: bool = False\nassert ok\n") == EXIT_WITH_ERROR def test_enum_variable(self): - assert run_seq_wasm( - "c: Ref.DpDemo.ColorEnum = Ref.DpDemo.ColorEnum.RED\nassert True\n" - ) == NO_ERROR + assert ( + run_seq_wasm( + "c: Ref.DpDemo.ColorEnum = Ref.DpDemo.ColorEnum.RED\nassert True\n" + ) + == NO_ERROR + ) def test_struct_variable(self): # Aggregate alloca + store of a struct constant. - assert run_seq_wasm( - "p: Ref.SignalPair = Ref.SignalPair(3, 4)\nassert True\n" - ) == NO_ERROR + assert ( + run_seq_wasm("p: Ref.SignalPair = Ref.SignalPair(3, 4)\nassert True\n") + == NO_ERROR + ) def test_array_variable(self): - assert run_seq_wasm( - "a: Ref.DpDemo.U32Array = [1, 2, 3]\nassert True\n" - ) == NO_ERROR + assert ( + run_seq_wasm("a: Ref.DpDemo.U32Array = [1, 2, 3]\nassert True\n") + == NO_ERROR + ) def test_aggregate_copy(self): # Reading an aggregate variable (load of a struct) and storing it. - assert run_seq_wasm( - "p: Ref.SignalPair = Ref.SignalPair(3, 4)\n" - "q: Ref.SignalPair = p\nassert True\n" - ) == NO_ERROR + assert ( + run_seq_wasm( + "p: Ref.SignalPair = Ref.SignalPair(3, 4)\n" + "q: Ref.SignalPair = p\nassert True\n" + ) + == NO_ERROR + ) class TestWasmArithmetic: @@ -200,7 +211,10 @@ def test_floor_divide_signed(self): def test_floor_divide_float(self): assert run_seq_wasm("x: F64 = 7.5\nassert x // 2.0 == 3.0\n") == NO_ERROR # Floored, not truncated: -5.5 // 2.0 == -3.0. - assert run_seq_wasm("x: F64 = 0.0 - 5.5\nassert x // 2.0 == (0.0 - 3.0)\n") == NO_ERROR + assert ( + run_seq_wasm("x: F64 = 0.0 - 5.5\nassert x // 2.0 == (0.0 - 3.0)\n") + == NO_ERROR + ) def test_greater_than_unsigned(self): assert run_seq_wasm("x: U64 = 5\nassert x > 3\n") == NO_ERROR @@ -347,7 +361,10 @@ def test_assignment_inside_if_visible_after(self): assert run_seq_wasm(seq) == NO_ERROR def test_assert_inside_if_body(self): - assert run_seq_wasm("x: U32 = 7\nif x == 7:\n assert False\n") == EXIT_WITH_ERROR + assert ( + run_seq_wasm("x: U32 = 7\nif x == 7:\n assert False\n") + == EXIT_WITH_ERROR + ) def test_variable_declared_in_if_block(self): # A var declared in a top-level if block is block-scoped (a local, not a @@ -374,14 +391,20 @@ class TestWasmCast: def test_float_to_int_truncates_toward_zero(self): # 5.9 -> 5: float->int truncates toward zero (wasm trunc / C / the VM). - assert run_seq_wasm("x: F64 = 5.9\ny: I32 = I32(x)\nassert y == 5\n") == NO_ERROR + assert ( + run_seq_wasm("x: F64 = 5.9\ny: I32 = I32(x)\nassert y == 5\n") == NO_ERROR + ) def test_negative_float_to_int_truncates_toward_zero(self): # -5.9 -> -5 (toward zero), not -6 (toward -inf). - assert run_seq_wasm("x: F64 = -5.9\ny: I32 = I32(x)\nassert y == -5\n") == NO_ERROR + assert ( + run_seq_wasm("x: F64 = -5.9\ny: I32 = I32(x)\nassert y == -5\n") == NO_ERROR + ) def test_int_to_float(self): - assert run_seq_wasm("x: I32 = 7\ny: F64 = F64(x)\nassert y == 7.0\n") == NO_ERROR + assert ( + run_seq_wasm("x: I32 = 7\ny: F64 = F64(x)\nassert y == 7.0\n") == NO_ERROR + ) def test_int_narrowing_wraps(self): # Narrowing an int truncates the high bits: 300 & 0xff == 44. @@ -403,11 +426,11 @@ class TestWasmFloatToIntSaturates: @pytest.mark.parametrize( "seq", [ - "x: F64 = 1e20\nassert U8(x) == 255\n", # above U8 max -> 255 - "x: F64 = -5.0\nassert U8(x) == 0\n", # below U8 min -> 0 - "x: F64 = 1000.0\nassert I8(x) == 127\n", # above I8 max -> 127 - "x: F64 = -1000.0\nassert I8(x) == -128\n", # below I8 min -> -128 - "x: F64 = 1e20\nassert I32(x) == 2147483647\n", # I32 max + "x: F64 = 1e20\nassert U8(x) == 255\n", # above U8 max -> 255 + "x: F64 = -5.0\nassert U8(x) == 0\n", # below U8 min -> 0 + "x: F64 = 1000.0\nassert I8(x) == 127\n", # above I8 max -> 127 + "x: F64 = -1000.0\nassert I8(x) == -128\n", # below I8 min -> -128 + "x: F64 = 1e20\nassert I32(x) == 2147483647\n", # I32 max "x: F64 = -1e20\nassert I32(x) == -2147483648\n", # I32 min ], ) @@ -416,13 +439,17 @@ def test_out_of_range_saturates(self, seq): def test_nan_to_int_is_zero(self): # 0.0 / 0.0 is NaN; a NaN float->int cast saturates to 0. - assert run_seq_wasm("x: F64 = 0.0\ny: F64 = x / x\nassert I32(y) == 0\n") == NO_ERROR + assert ( + run_seq_wasm("x: F64 = 0.0\ny: F64 = x / x\nassert I32(y) == 0\n") + == NO_ERROR + ) def test_infinity_to_int_saturates(self): # +inf clamps to the target max rather than trapping or crashing. - assert run_seq_wasm( - "x: F64 = 1e308\nx = x * 10.0\nassert I32(x) == 2147483647\n" - ) == NO_ERROR + assert ( + run_seq_wasm("x: F64 = 1e308\nx = x * 10.0\nassert I32(x) == 2147483647\n") + == NO_ERROR + ) def test_out_of_range_does_not_trap(self): # Runs to completion (returns a code) rather than trapping; a wasm trap From 285dd6c45156001b6a9e8cb7dfbabb4d6871528d Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Sun, 5 Jul 2026 11:34:22 -0400 Subject: [PATCH 06/10] Add pre commit hooks for formatting and checking spec test links --- .pre-commit-config.yaml | 16 +++ README.md | 19 ++- SPEC.md | 112 +++++++++++++++- pyproject.toml | 6 + uv.lock | 288 ++++++++++++++++++++++++++++++++++++++++ verify/spec_links.py | 135 +++++++++++++++++++ 6 files changed, 571 insertions(+), 5 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 verify/spec_links.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..89cfe68 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# Install into .git/hooks with: uv run pre-commit install +# Both hooks run through the project venv (uv), so uv sync is the only setup. +repos: + - repo: local + hooks: + - id: black + name: black + entry: uv run black + language: system + types: [python] + - id: spec-links + name: spec test links + entry: uv run python verify/spec_links.py + language: system + pass_filenames: false + always_run: true diff --git a/README.md b/README.md index 4c4e051..3666de6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Fpy has a few principles: >*The art of making a good language is to restrict the user in a good way* > -> – Andrey Breslav, creator of Kotlin +> -- Andrey Breslav, creator of Kotlin ## Overview @@ -623,10 +623,21 @@ Fpy does not support a fully-fledged `string` type yet. You can pass a string li ## Workflow -1. Make a venv -2. `pip install -e .` +This project uses [uv](https://docs.astral.sh/uv/) to manage its environment. + +1. `uv sync` (creates `.venv` and installs all dependencies, including dev tools) +2. `uv run pre-commit install` (one-time: installs the git pre-commit hooks) 3. Make changes to the source -4. `pytest` +4. `uv run pytest` + +### Pre-commit hooks + +The hooks are defined in `.pre-commit-config.yaml` and run automatically on `git commit` once installed: + +* **black** formats the staged Python files. +* **spec test links** runs `verify/spec_links.py`, which checks that every test link in `SPEC.md` (the `*Tests:*` lines) points at a test that exists, at its current line number. If this hook fails because tests moved or were renamed, run `uv run python verify/spec_links.py --fix` to update the line numbers and link labels, then re-stage `SPEC.md`. A missing or renamed test must be fixed by hand. + +You can run all hooks against the whole repo at any time with `uv run pre-commit run --all-files`. ## Running on a test F Prime deployment diff --git a/SPEC.md b/SPEC.md index cf7bff3..12c1e4c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -22,6 +22,8 @@ In a syntactic rule: * A star suffix `*` means zero or more instances of its preceding rule * A question mark suffix `?` means zero or one instances of its preceding rule +A line of the specification may be followed by an italicized *Tests:* line. Each bracketed number links to a test that verifies the behavior; hover over a number to see the test's full `file::class::name`. These links are checked and kept up to date by `verify/spec_links.py`. + # Names and scopes ## Names @@ -44,6 +46,7 @@ The list of reserved words is: * `def` * `for` * `if` +* `import` * `not` * `pass` * `return` @@ -137,6 +140,9 @@ A **fully-qualified name** is a qualified name which is not itself a qualifier. TODO names are semantic, ident is syntactic TODO you can't actually tell at syntax level what is a fqn If a fully-qualified name resolves to a namespace, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L343 "test/fpy/test_imports.py::TestImportNamespaceIsolation::test_module_name_not_usable_as_value") + TODO you can think of the dict as "importing definitions" @@ -622,6 +628,110 @@ If the check times out during execution: If at any point during execution, two times which are [incomparable](todo) are attempted to be compared, the check statement will halt the program as if by an [assertion](#assert-statement), and display an error code. +# Imports + +> **Note:** The import statement is not yet implemented. + +An **import statement** compiles another Fpy source file, called a **module**, and makes the module's [definitions](#definitions) available in the importing file under a [namespace](#namespaces). + +## Syntax + +Rule: + +`import_stmt: "import" name ("." name)*` + +Name: + +`import_stmt: "import" module_path` + +The **module path** is the entire dotted sequence of names. The first name of a module path is its **root segment**, and the last is its **leaf segment**. + +An import statement is only valid outside an indentation block. + +*Tests:* [1](test/fpy/test_imports.py#L535 "test/fpy/test_imports.py::TestImportOnlyAtTopLevel::test_import_inside_if_block_fails"), [2](test/fpy/test_imports.py#L552 "test/fpy/test_imports.py::TestImportOnlyAtTopLevel::test_import_inside_function_fails") + +## Module resolution + +The **import search path** is an ordered list of directories provided by the environment in which the compiler is invoked. + +> In the command-line compiler, the import search path is the directory containing the file being compiled, followed by each directory passed with `-i`/`--include`, in order. + +A module path `s_0.s_1. ... .s_n` **resolves** in a directory `dir` if the file `dir/s_0/s_1/.../s_n.fpy` exists. + +*Tests:* [1](test/fpy/test_imports.py#L705 "test/fpy/test_imports.py::TestImportDottedPaths::test_single_dotted_import"), [2](test/fpy/test_imports.py#L725 "test/fpy/test_imports.py::TestImportDottedPaths::test_deeply_nested_dotted_import"), [3](test/fpy/test_imports.py#L801 "test/fpy/test_imports.py::TestImportPackagePrecedence::test_module_file_beats_namespace_directory"), [4](test/fpy/test_imports.py#L815 "test/fpy/test_imports.py::TestImportPackagePrecedence::test_package_dir_used_for_dotted_descent") + +The module path of an import statement is resolved in each directory of the import search path, in order. The file from the first directory in which it resolves is the imported module. + +*Tests:* [1](test/fpy/test_imports.py#L856 "test/fpy/test_imports.py::TestImportSearchDirs::test_module_found_in_later_search_dir"), [2](test/fpy/test_imports.py#L871 "test/fpy/test_imports.py::TestImportSearchDirs::test_first_search_dir_shadows_later"), [3](test/fpy/test_imports.py#L887 "test/fpy/test_imports.py::TestImportSearchDirs::test_search_order_respects_dir_order"), [4](test/fpy/test_imports.py#L903 "test/fpy/test_imports.py::TestImportSearchDirs::test_dotted_module_resolved_across_search_dirs") + +If the module path resolves in no directory of the import search path, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L246 "test/fpy/test_imports.py::TestImportErrors::test_missing_module_is_an_error"), [2](test/fpy/test_imports.py#L919 "test/fpy/test_imports.py::TestImportSearchDirs::test_no_search_dirs_cannot_resolve"), [3](test/fpy/test_imports.py#L763 "test/fpy/test_imports.py::TestImportDottedPaths::test_missing_leaf_in_existing_package_is_error"), [4](test/fpy/test_imports.py#L828 "test/fpy/test_imports.py::TestImportPackagePrecedence::test_bare_package_import_is_error"), [5](test/fpy/test_imports.py#L841 "test/fpy/test_imports.py::TestImportPackagePrecedence::test_dotted_leaf_package_import_is_error"), [6](test/fpy/test_imports.py#L297 "test/fpy/test_imports.py::TestImportFileErrors::test_import_path_is_a_directory_fails") + +> Every segment except the leaf names a plain directory; no `__init__.fpy`-style marker file is required. Because only the leaf's `.fpy` file satisfies an import, a file `foo.fpy` always takes precedence over a sibling directory `foo/` for `import foo`, while `import foo.bar` descends into the directory `foo/` regardless of whether `foo.fpy` exists. Importing a name that resolves only to a directory is an error: a directory has no code to import. + +## Semantics + +If the imported module fails to parse or compile, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L279 "test/fpy/test_imports.py::TestImportFileErrors::test_parse_error_in_imported_file_fails") + +> The diagnostic should point into the imported file, not at the import statement. + +If the imported module declares one or more [sequence arguments](todo), an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L228 "test/fpy/test_imports.py::TestImportErrors::test_cannot_import_sequence_with_arguments") + +> A `sequence()` directive with no arguments does not prevent a file from being imported. + +*Tests:* [1](test/fpy/test_imports.py#L254 "test/fpy/test_imports.py::TestImportErrors::test_no_arg_sequence_is_importable") + +An imported module may itself contain import statements. The semantics of this section apply to them recursively, with the module in the role of the importing file. All import statements in a compilation resolve against the same import search path. + +*Tests:* [1](test/fpy/test_imports.py#L574 "test/fpy/test_imports.py::TestImportTransitive::test_transitive_import_works") + +If a module transitively imports itself, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L635 "test/fpy/test_imports.py::TestImportCycles::test_self_import_is_cycle_error"), [2](test/fpy/test_imports.py#L656 "test/fpy/test_imports.py::TestImportCycles::test_mutual_import_is_cycle_error"), [3](test/fpy/test_imports.py#L687 "test/fpy/test_imports.py::TestImportCycles::test_three_way_cycle_error") + +The import statement introduces the module path as a chain of namespaces in the global scope. Each [definition](#definitions) at the top level of the module adds its symbol to the leaf namespace: a symbol `x` defined in module `a.b.c` is named `a.b.c.x` in the importing file, and is not accessible under any shorter name. + +*Tests:* [1](test/fpy/test_imports.py#L85 "test/fpy/test_imports.py::TestImportInlining::test_call_imported_function"), [2](test/fpy/test_imports.py#L123 "test/fpy/test_imports.py::TestImportInlining::test_local_and_imported_names_coexist"), [3](test/fpy/test_imports.py#L324 "test/fpy/test_imports.py::TestImportNamespaceIsolation::test_imported_symbol_requires_module_prefix"), [4](test/fpy/test_imports.py#L362 "test/fpy/test_imports.py::TestImportNamespaceIsolation::test_same_function_name_in_two_modules_no_collision"), [5](test/fpy/test_imports.py#L743 "test/fpy/test_imports.py::TestImportDottedPaths::test_dotted_symbol_requires_full_path") + +Per the [name group](#name-groups) rules, these namespaces exist only in the name groups of the symbols they contain. + +*Tests:* [1](test/fpy/test_imports.py#L461 "test/fpy/test_imports.py::TestImportNameCollisions::test_import_coexists_with_local_variable") + +If a name introduced by an import statement is already mapped, in the same name group and the same scope or namespace, to anything other than a namespace introduced by another import statement, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L421 "test/fpy/test_imports.py::TestImportNameCollisions::test_import_collides_with_local_function"), [2](test/fpy/test_imports.py#L442 "test/fpy/test_imports.py::TestImportNameCollisions::test_import_collides_with_local_variable") + +> Namespaces introduced by imports merge: after `import pkg.a` and `import pkg.b`, the namespace `pkg` contains both `a` and `b`. Because name groups do not intersect, an imported module conflicts only with names in the groups it occupies: a variable `lib` may coexist with an imported module `lib` that defines only functions. + +*Tests:* [1](test/fpy/test_imports.py#L780 "test/fpy/test_imports.py::TestImportDottedPaths::test_two_modules_in_same_package_no_collision") + +Names within the module are resolved in the module's own global scope. Symbols of the importing file are not visible in the module, and modules imported by the module are not visible in the importing file. + +*Tests:* [1](test/fpy/test_imports.py#L392 "test/fpy/test_imports.py::TestImportNamespaceIsolation::test_imported_function_cannot_see_importer_globals"), [2](test/fpy/test_imports.py#L602 "test/fpy/test_imports.py::TestImportTransitive::test_transitive_dependency_is_private") + +If the same module path is imported more than once in the same file, an error is raised. + +*Tests:* [1](test/fpy/test_imports.py#L489 "test/fpy/test_imports.py::TestImportDuplicates::test_duplicate_import_is_error") + +> This rule is per-file: a file and a module it imports may each import the same module. + +*Tests:* [1](test/fpy/test_imports.py#L506 "test/fpy/test_imports.py::TestImportDuplicates::test_duplicate_across_files_is_allowed") + +If the imported module contains top-level statements other than function definitions and import statements, the `import-side-effects` warning is emitted. + +*Tests:* [1](test/fpy/test_imports.py#L158 "test/fpy/test_imports.py::TestImportSideEffects::test_side_effecting_import_warns"), [2](test/fpy/test_imports.py#L172 "test/fpy/test_imports.py::TestImportSideEffects::test_side_effect_warning_can_be_ignored"), [3](test/fpy/test_imports.py#L187 "test/fpy/test_imports.py::TestImportSideEffects::test_side_effect_warning_can_be_escalated"), [4](test/fpy/test_imports.py#L202 "test/fpy/test_imports.py::TestImportSideEffects::test_functions_only_module_does_not_warn"), [5](test/fpy/test_imports.py#L308 "test/fpy/test_imports.py::TestImportFileErrors::test_empty_module_compiles_without_warning") + +At execution, the imported module's top-level statements execute as part of the importing sequence, at the position of the import statement, in order. + +*Tests:* [1](test/fpy/test_imports.py#L106 "test/fpy/test_imports.py::TestImportInlining::test_imported_function_runs"), [2](test/fpy/test_imports.py#L932 "test/fpy/test_imports.py::TestImportVariables::test_top_level_variable_is_side_effect_and_namespaced") + +> Importing is compile-time source inlining: the imported module does not exist in the compiled output, and no files are resolved or loaded at run time. This is what motivates the `import-side-effects` warning: a file written to run as a standalone sequence typically has top-level commands, and importing it splices that code into the importing sequence, which is rarely intended. A file meant to be imported should contain only definitions. Note that a top-level variable definition is such a side effect (its initialization executes at the import site), while still defining a namespaced symbol: `counter: U32 = 5` in module `m` warns, and is thereafter accessible as `m.counter`. + # Callables A **callable** is a symbol with parameters and a return [type](#types) which can be evaluated by being called. @@ -1238,7 +1348,7 @@ In general, the rule of thumb is that coercion is allowed if the destination typ * Arbitrary-precision types (`Int`/`Float`) may coerce to any finite-width numeric type. If no rule matches, the compiler raises an error. -Compile-time constant floats (including literals and constant-folded expressions) can only be narrowed into a smaller floating-point type when the value lies inside the destination’s representable range. When the value fits, the compiler rounds it to the nearest representable floating-point number; otherwise compilation fails with an out-of-range error. +Compile-time constant floats (including literals and constant-folded expressions) can only be narrowed into a smaller floating-point type when the value lies inside the destination's representable range. When the value fits, the compiler rounds it to the nearest representable floating-point number; otherwise compilation fails with an out-of-range error. # Execution diff --git a/pyproject.toml b/pyproject.toml index eac7792..8375dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ fprime-fpy-depend = "fpy.main:depend_main" #### [tool.setuptools_scm] +[dependency-groups] +dev = [ + "black>=26.5.1", + "pre-commit>=4.6.0", +] + #### # Additional notes diff --git a/uv.lock b/uv.lock index 2e263c4..0d186b2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,71 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,6 +76,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -23,6 +97,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "filelock" +version = "3.29.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, +] + [[package]] name = "fprime-fpy" source = { editable = "." } @@ -34,6 +117,12 @@ dependencies = [ { name = "ziglang" }, ] +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "pre-commit" }, +] + [package.metadata] requires-dist = [ { name = "lark", specifier = ">=1.2.2" }, @@ -43,6 +132,21 @@ requires-dist = [ { name = "ziglang", specifier = ">=0.16.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=26.5.1" }, + { name = "pre-commit", specifier = ">=4.6.0" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -93,6 +197,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -102,6 +224,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -111,6 +251,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -138,6 +294,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-discovery" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -201,6 +473,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + [[package]] name = "wasmtime" version = "45.0.0" diff --git a/verify/spec_links.py b/verify/spec_links.py new file mode 100644 index 0000000..676cfc4 --- /dev/null +++ b/verify/spec_links.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Verify spec-to-test links in markdown files. + +The spec references the tests that pin down a statement with compact numbered +markdown links. The link title (shown on hover) names the test, and the URL +points at its line: + + *Tests:* [1](test/fpy/test_imports.py#L123 "test/fpy/test_imports.py::TestFoo::test_bar"), [2](...) + +Any markdown link whose title contains `.py::` is treated as a test link. +The title must be `::::` (or `::` for +a module-level test), with relative to the markdown file's directory. +The URL must be `#L` where is the line of the test's +`def`. The link label must be the link's 1-based position among the test +links on its markdown line. + +Usage: + + python3 verify/spec_links.py [--fix] [FILE.md ...] + +Without --fix, exits nonzero if any link names a missing file or test, has a +stale line number, or is mislabeled. With --fix, stale line numbers and +labels are rewritten in place; missing files and tests are still errors. +""" + +import argparse +import ast +import re +import sys +from pathlib import Path + +TEST_LINK_RE = re.compile( + r'\[(?P