From 1b75d535f75113ccd60cb8accb8e4a83d235a134 Mon Sep 17 00:00:00 2001 From: said Date: Mon, 14 Sep 2026 05:37:58 +0100 Subject: [PATCH 01/24] Fix imported abstract interfaces and callback scalar outputs Wrapping PRIMA surfaced four defects in the callback path. A dummy procedure's interface name kept the casefolded key used to match it, so a generated .pyi annotated `procedure(OBJ)` as `obj` while importing `OBJ` and could not be rebuilt from its own contract. The parser now keeps the declared spelling and normalizes case at each comparison. An assumed-shape array in a callback prototype emitted the plan's runtime extent marker as Fortran text, `dimension(::Strided)`. The bridge now lowers a runtime extent to an assumed-shape dummy and measures the contiguous call-local copy from it. An array result has no caller descriptor to measure and reports that directly instead. An abstract interface imported from another file did not resolve during single-file conversion, so `generate --pyi a.f90 b.f90` degraded the dummy to an opaque placeholder. Resolution now matches multi-file builds, and an interface that no supplied source declares is reported by name. An `intent(out)` primitive scalar was projected as an independent value. Python has no writable scalar, so the write was silently discarded and the native caller read uninitialized memory. Such a dummy now reaches Python as rank-zero storage, and the value spelling is a policy error naming the replacement. A prototype describes a native callback interface, so it still mirrors the native argument list; `@native_call` projection remains available in the contract for a return-oriented callable. Plan validation covers the storage projection, which the scalar rule previously skipped. Callback parameters now document the exact callable they expect, generated from the same completed prototype the trampoline is built from, so the documented signature cannot drift from the real ABI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 33 ++++ docs/user/guide/callbacks.md | 51 +++++- prik/cli.py | 6 + prik/codegen/c/binding.py | 46 ++++- prik/codegen/docstrings.py | 52 +++++- prik/codegen/fortran/bridge.py | 80 +++++++-- prik/parsers/fortran/parser.py | 7 +- prik/pipeline/wrapper.py | 39 ++-- prik/policy/construction.py | 24 +++ prik/printers/pyi.py | 9 +- prik/semantics/fortran2ir.py | 70 +++++++- prik/semantics/models.py | 1 + prik/utilities/declaration_expressions.py | 7 +- .../codegen/test_callback_planning.py | 70 +++++++- .../fcallback_all_f90/fcallback_all_f90.pyi | 4 +- .../fcallback_array_f90.pyi | 14 +- .../fixtures/native/fcallback_array_f90.f90 | 13 ++ .../end_to_end/test_array_callbacks.py | 29 +++ .../test_callback_scalar_storage.py | 168 ++++++++++++++++++ .../callbacks/policy/test_callback_policy.py | 107 +++++++++++ .../test_fortran_callback_semantics.py | 67 +++++++ 21 files changed, 848 insertions(+), 49 deletions(-) create mode 100644 tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 746c4224f..7c8d6ce49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ release tags add a leading `v` to the package version. ## Unreleased +- An `intent(out)` or `intent(inout)` primitive scalar in a callback prototype + now reaches Python as rank-zero storage (`Out(Float64[()])`) instead of an + independent value, so the value the callback computes reaches the native + caller. Python has no writable scalar, so the previous `Out(Addr(T))` spelling + silently discarded the write; it is now a policy error naming the replacement. + A prototype still mirrors the native argument list — edit it with + `@native_call` to project an output into the callable's return value instead. + +- Generated docstrings now state a callback's exact callable signature — + arity, per-argument direction and element type, how an output is delivered, + and the lifetime and fatal-error rules — taken from the same completed + prototype the trampoline is generated from. + +- Assumed-shape array arguments are now supported inside a callback prototype. + A `procedure(iface)` dummy whose interface declares `values(:)` lowers to an + assumed-shape bridge dummy and a contiguous call-local copy measured from it, + instead of emitting an invalid array declaration. Array callback *results* + still require an exact shape and now report that directly. + +- A dummy procedure's interface name keeps the spelling it was declared with. + Generated `.pyi` contracts previously annotated `procedure(OBJ)` as `obj` + while importing `OBJ`, so PRIK could not rebuild from the contract it had + just written. + +- `prik generate --pyi` now resolves an abstract interface imported from + another supplied source file, matching multi-file wrapper builds. + +- A `procedure(iface)` dummy whose interface no supplied source declares now + reports the interface by name and asks for the module that declares it, + instead of failing against an opaque placeholder type. Contract extraction + spells that interface name so the generated `.pyi` stays consistent with the + import it already emits. + ## 0.5.0 — 2026-09-13 - Added CMake integration through the packaged `UsePRIK.cmake` helper and a diff --git a/docs/user/guide/callbacks.md b/docs/user/guide/callbacks.md index 475311d1b..779d8a05b 100644 --- a/docs/user/guide/callbacks.md +++ b/docs/user/guide/callbacks.md @@ -216,6 +216,14 @@ copying an undefined incoming value, and `InOut(...)` copies the incoming value and writes changes back after the callback. Omitting the wrapper preserves an omitted Fortran `intent` rather than inventing one. +An assumed-shape callback dummy is spelled `Float64[::]`, and Python receives +the extent the native caller passed: + +| Fortran callback dummy | Matching prototype | +| --- | --- | +| `real(8), intent(in) :: values(count)` | `values: In(Float64[count])` | +| `real(8), intent(in) :: values(:)` | `values: In(Float64[::])` | + For scalar arguments, choose the spelling from the Fortran callback dummy: | Fortran callback dummy | Matching prototype | @@ -226,6 +234,43 @@ For scalar arguments, choose the spelling from the Fortran callback dummy: Both forms call Python with an independent `np.float64` scalar. The difference is the native calling convention PRIK must match. +A dummy the native caller reads back after the call is different: PRIK generates +rank-zero storage for it, because Python has no writable scalar. + +| Fortran callback dummy | Generated prototype | +| --- | --- | +| `real(8), intent(out) :: f` | `f: Out(Float64[()])` | +| `real(8), intent(inout) :: f` | `f: InOut(Float64[()])` | + +Python receives a rank-zero NumPy view of the native storage. Assign through it; +rebinding the name changes nothing the native caller will read: + +```python +def objective(x, f): + f[...] = float(np.sum(x * x)) # delivers the value + f = float(np.sum(x * x)) # rebinds a local name; the caller sees nothing +``` + +To keep an ordinary Python function, write a small adapter and pass that: + +```python +def objective(x): + return float(np.sum(x * x)) + +def objective_prik(x, f): + f[...] = objective(x) +``` + +A prototype keeps the native callback's argument list, so the Python callable +mirrors the Fortran interface. To call a return-style function instead, edit the +prototype to project the output: + +```python +@prototype +@native_call([Arg(0), Return("f", 0)]) +def OBJ(x: In(Float64[::])) -> Float64: ... +``` + `Value(T)` is only for supported non-primitive scalar value dummies, such as a derived-type callback dummy declared with the Fortran `value` attribute. @@ -238,8 +283,8 @@ derived-type callback dummy declared with the Fortran `value` attribute. - Return the exact NumPy scalar type when PRIK expects a scalar callback result. - Primitive scalar callback arguments arrive as independent NumPy scalar values, whether the native dummy is `value` or reference. -- Primitive scalar reference writeback is unsupported; return a scalar result - instead. +- Primitive scalar `in` arguments arrive as independent values; `out` and + `inout` arguments arrive as rank-zero storage you assign through. - Arrays and derived-type arguments can expose live native state; copy data you need after the wrapped call returns. @@ -269,7 +314,7 @@ The current callback contract does not support: or supported scalar derived types. - Arrays passed by Fortran `value`, arrays of derived values, and array callback results without a complete fixed shape. Pass arrays by reference and give array - results an exact primitive shape. + results an exact primitive shape; an array *argument* may be assumed-shape. - Variable-length callback strings. Use a fixed positive `String[n]` length. - Callback execution on a different Python thread. The callback must run on the same thread that entered the wrapper. diff --git a/prik/cli.py b/prik/cli.py index 0c2f0cd7f..40e57d06b 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -617,6 +617,9 @@ def _convert_fortran_semantic_sources( refresh=context.refresh_fortran_type_probe, ) converted_files = [] + # A module that imports an abstract interface from another supplied file + # must resolve it here, exactly as a multi-file wrapper build does. + modules_by_file = {id(fobj): list(fobj.modules) for _p, fobj in parsed_files} for p, fobj in parsed_files: compile_time_values = _fortran_compile_time_values(fobj, context.preprocessing, **probe_options) type_facts = _fortran_type_facts( @@ -631,6 +634,9 @@ def _convert_fortran_semantic_sources( compile_time_values=compile_time_values, wrapped_derived_types=wrapped_derived_types, assume_intent_in_scalars=context.assume_intent_in_scalars, + sibling_modules=[ + module for key, modules in modules_by_file.items() if key != id(fobj) for module in modules + ], **({"type_facts": type_facts} if type_facts is not None else {}), ) converted_files.append((p, modules)) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index e52704e4a..06655f213 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -14,7 +14,11 @@ import re from typing import ClassVar -from prik.utilities.declaration_expressions import declaration_extent_uses_power, render_declaration_extent +from prik.utilities.declaration_expressions import ( + RUNTIME_EXTENT_MARKERS, + declaration_extent_uses_power, + render_declaration_extent, +) from prik.policy.ownership import ( CodegenAction, ObjectKind, @@ -230,7 +234,6 @@ class CBindingGenerator(ClassVisitor): class; unsupported plan actions fail instead of being reinterpreted here. """ - _RUNTIME_EXTENT_MARKERS = frozenset({":", "::Strided", "Flat"}) _SHARED_OUTPUT_CLEANUP_MIN_RESULTS = 4 def require_supported(self, plan: ModulePlan) -> None: @@ -981,6 +984,8 @@ def _callback_python_argument_nodes( match transfer.python_action: case PythonBarrierAction.SCALAR_VALUE: nodes = self._callback_scalar_value_nodes(transfer, target) + case PythonBarrierAction.SCALAR_STORAGE: + nodes = self._callback_scalar_storage_nodes(transfer, target) case PythonBarrierAction.ARRAY_STORAGE: nodes = self._callback_array_nodes(transfer, position, target) case PythonBarrierAction.STRING_STORAGE: @@ -1019,6 +1024,41 @@ def _callback_scalar_value_nodes( ), ) + def _callback_scalar_storage_nodes( + self, + transfer: CallbackTransferPlan, + target: str, + ) -> tuple[CDeclaration, ...]: + """Materialize one completed rank-zero storage projection over native memory. + + The Python callable receives a rank-zero view of the same storage the + adapter hands the native caller, so an ``out`` or ``inout`` dummy is + written through instead of arriving as an independent value. + """ + if transfer.abi is not CallbackABIKind.REFERENCE: + raise ValueError( + f"Unsupported rank-zero storage callback ABI for {transfer.owner_path!r}: {transfer.abi.value}" + ) + scalar = PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name) + parameter = self._callback_parameter_base_name(transfer) + flags = "NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED" + if transfer.adapter_action in { + CallbackTransferAction.COPY_OUT, + CallbackTransferAction.COPY_IN_OUT, + CallbackTransferAction.BORROW_WRITABLE, + }: + flags += " | NPY_ARRAY_WRITEABLE" + return ( + CDeclaration( + target, + "PyObject *", + CodeExpression( + f"PyArray_New(&PyArray_Type, 0, NULL, {scalar.numpy_type_macro}, " + f"NULL, {parameter}_data, 0, {flags}, NULL)" + ), + ), + ) + def _callback_array_nodes( self, transfer: CallbackTransferPlan, @@ -8028,7 +8068,7 @@ def _outlined_array_bind_axis_value( flattened: bool, ) -> str | None: """Lower one axis extent, or None when the axis carries no declared extent.""" - if flattened or expression in self._RUNTIME_EXTENT_MARKERS: + if flattened or expression in RUNTIME_EXTENT_MARKERS: return None if array.extent_evaluation[axis] == "bridge": return None diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 50cd5e351..bc0c826f7 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -24,6 +24,7 @@ ArrayHandoffPlan, BindingStatusErrorPlan, CallbackHandoffPlan, + CallbackResultPlan, CallbackTransferPlan, ClassMethodPlan, ClassSurfacePlan, @@ -748,6 +749,7 @@ def _argument_lines(self, argument: ArgumentTransferPlan) -> tuple[str, ...]: optional = argument.binding.optional_mode not in {OptionalMode.REQUIRED, OptionalMode.REQUIRED_DESCRIPTOR} nullable = optional or argument.binding.nullable lines = [f"{argument.binding.python_name} : {self._type(argument, nullable=nullable, signature=False)}"] + lines.extend(self._callback_signature_lines(argument)) lines.extend(self._array_lines(argument.array)) lines.extend(self._native_c_array_storage_lines(argument)) lines.extend(self._optional_lines(argument)) @@ -967,19 +969,59 @@ def _callback_type(self, callback: CallbackHandoffPlan | None) -> str: @staticmethod def _callback_transfer_type(transfer: CallbackTransferPlan) -> str: - """Render a callback prototype argument or result from completed ABI facts. + """Render one callback prototype dummy as the Python object it receives. - Derived transfers preserve their type identity. Arrays and reference - ABI transfers render as NumPy arrays; other transfers use the scalar - map. The helper is pure and does not inspect outer wrapper policy. + The spelling follows the completed Python projection rather than the + native ABI: a dummy projected as storage arrives as an array the + callable can write through, and one projected as a value does not. """ if transfer.derived_type_identity is not None: return transfer.semantic_type_name scalar = _SCALAR_TYPES.get(transfer.semantic_type_name, transfer.semantic_type_name) - if transfer.array is not None or transfer.abi.value == "reference": + if transfer.python_action in {PythonBarrierAction.ARRAY_STORAGE, PythonBarrierAction.SCALAR_STORAGE}: return f"ndarray[{scalar}]" return scalar + def _callback_signature_lines(self, argument: ArgumentTransferPlan) -> tuple[str, ...]: + """Document the exact callable one callback parameter expects. + + Every fact comes from the completed prototype the trampoline is + generated from, so the documented arity, direction and access cannot + drift from the callable the native caller actually invokes. + """ + callback = argument.callback + if callback is None: + return () + parameters = ", ".join(transfer.name for transfer in callback.arguments) + result = self._callback_result_type(callback.result) + return ( + f" Called as: {argument.binding.python_name}({parameters}) -> {result}", + *(f" {self._callback_parameter_text(transfer)}" for transfer in callback.arguments), + " Valid only during this call; do not retain the callable or its arguments.", + " An exception or an invalid return value terminates the process.", + ) + + @staticmethod + def _callback_parameter_text(transfer: CallbackTransferPlan) -> str: + """Render one prototype dummy with the access its projection allows.""" + text = f"{transfer.name} : {WrapperDocstringBuilder._callback_transfer_type(transfer)}" + if transfer.intent is not None: + text += f", intent({transfer.intent})" + if transfer.python_action is PythonBarrierAction.SCALAR_STORAGE: + text += f"; assign through it ({transfer.name}[...] = value)" + return text + + @staticmethod + def _callback_result_type(result: CallbackResultPlan) -> str: + """Render what the callable must return, or ``None`` for a subroutine.""" + transfer = result.transfer + if transfer is None: + return "None" + if transfer.derived_type_identity is not None: + return transfer.semantic_type_name + scalar = _SCALAR_TYPES.get(transfer.semantic_type_name, transfer.semantic_type_name) + return f"ndarray[{scalar}]" if transfer.array is not None else scalar + @staticmethod def _array_lines(array: ArrayHandoffPlan | None) -> tuple[str, ...]: """Render rank, resolved display shape, and layout notes for one array facet. diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index b0ecc0514..e19c7a825 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -14,7 +14,7 @@ import re from prik.naming.native_symbols import NativeSymbolNames -from prik.utilities.declaration_expressions import render_declaration_extent +from prik.utilities.declaration_expressions import RUNTIME_EXTENT_MARKERS, render_declaration_extent from prik.policy.ownership import ( AssignmentMode, CodegenAction, @@ -858,7 +858,7 @@ def _callback_native_parameter(self, transfer: CallbackTransferPlan) -> FortranP }: attributes.append("target") if transfer.rank: - attributes.append(f"dimension({self._callback_shape(transfer)})") + attributes.append(f"dimension({self._callback_dummy_shape(transfer)})") return FortranParameter( self._callback_parameter_base_name(transfer), self._callback_native_type(transfer), @@ -932,7 +932,7 @@ def _callback_transfer_declarations( }: attributes = ["target"] if transfer.rank: - attributes.append(f"dimension({self._callback_shape(transfer)})") + attributes.append(f"dimension({self._callback_storage_shape(transfer)})") declarations.append( FortranDeclaration( self._callback_storage_name(transfer), @@ -1062,7 +1062,7 @@ def _callback_result_reconstruction( ( CodeExpression("callback_result_data"), CodeExpression("callback_result_view"), - CodeExpression(f"[{self._callback_shape(transfer)}]"), + CodeExpression(f"[{self._callback_result_shape(transfer)}]"), ), ), FortranAssignment("callback_result", CodeExpression("callback_result_view")), @@ -1086,7 +1086,7 @@ def _callback_native_result_type(self, transfer: CallbackTransferPlan | None) -> raise ValueError("Callback function result is missing its transfer plan") result_type = self._callback_native_type(transfer) if transfer.rank: - result_type += f", dimension({self._callback_shape(transfer)})" + result_type += f", dimension({self._callback_result_shape(transfer)})" return result_type def _callback_native_type(self, transfer: CallbackTransferPlan) -> str: @@ -1104,14 +1104,54 @@ def _callback_parameter_base_name(transfer: CallbackTransferPlan) -> str: """Return the base Fortran dummy name reserved for one callback transfer.""" return re.sub(r"\W", "_", transfer.name).casefold() - def _callback_shape(self, transfer: CallbackTransferPlan) -> str: - """Render completed callback extents in native Fortran syntax.""" + def _callback_array_shape(self, transfer: CallbackTransferPlan) -> tuple[str, ...]: + """Return one callback transfer's completed public extent expressions.""" if transfer.array is None or transfer.array.rank is None: raise ValueError(f"Callback array transfer {transfer.owner_path!r} has no shape plan") + return tuple(transfer.array.shape) + + def _callback_dummy_shape(self, transfer: CallbackTransferPlan) -> str: + """Render one callback dummy's extents in native Fortran syntax. + + A runtime extent lowers to an assumed-shape axis, so the dummy takes + the native caller's descriptor. The contiguous call-local copy + declared beside it carries the concrete bounds instead. + """ + return ", ".join( + ":" if expression in RUNTIME_EXTENT_MARKERS else render_declaration_extent(expression, {}, target="fortran") + for expression in self._callback_array_shape(transfer) + ) + + def _callback_storage_shape(self, transfer: CallbackTransferPlan) -> str: + """Render the contiguous call-local copy's extents for one callback dummy. + + An assumed-shape dummy cannot back ``c_loc``, so the copy is an + automatic array measured from the dummy it was declared beside. + """ + base = self._callback_parameter_base_name(transfer) return ", ".join( - render_declaration_extent(expression, {}, target="fortran") for expression in transfer.array.shape + f"size({base}, {axis + 1})" + if expression in RUNTIME_EXTENT_MARKERS + else render_declaration_extent(expression, {}, target="fortran") + for axis, expression in enumerate(self._callback_array_shape(transfer)) ) + def _callback_result_shape(self, transfer: CallbackTransferPlan) -> str: + """Render a callback array result's extents, which must be explicit. + + A function result has no caller descriptor to measure, so a runtime + extent here means policy admitted a form the native result cannot + spell. + """ + shape = self._callback_array_shape(transfer) + runtime = [expression for expression in shape if expression in RUNTIME_EXTENT_MARKERS] + if runtime: + raise ValueError( + f"Callback array result {transfer.owner_path!r} has runtime extents {runtime} " + "and cannot be spelled as a native function result" + ) + return ", ".join(render_declaration_extent(expression, {}, target="fortran") for expression in shape) + def _callback_address_source(self, transfer: CallbackTransferPlan) -> str: """Return the C-address expression that backs one callback transfer.""" if transfer.adapter_action in { @@ -8536,7 +8576,7 @@ def _procedure_prototype_result_type( """Declare one exact function result from the shared prototype plan.""" result_type = self._procedure_prototype_type(result) if result.rank: - result_type += f", dimension({self._procedure_prototype_shape(result.array, result.owner_path)})" + result_type += f", dimension({self._procedure_prototype_result_shape(result.array, result.owner_path)})" return result_type def _procedure_prototype_type( @@ -8554,9 +8594,29 @@ def _procedure_prototype_type( @staticmethod def _procedure_prototype_shape(array: ArrayHandoffPlan | None, owner_path: str) -> str: - """Render an exact prototype array shape without backend role substitution.""" + """Render a prototype dummy's shape without backend role substitution. + + A runtime extent lowers to an assumed-shape axis so the interface body + matches the native declaration it describes. + """ + if array is None or array.rank is None: + raise ValueError(f"Prototype value {owner_path!r} has no concrete shape") + return ", ".join( + ":" if expression in RUNTIME_EXTENT_MARKERS else render_declaration_extent(expression, {}, target="fortran") + for expression in array.shape + ) + + @staticmethod + def _procedure_prototype_result_shape(array: ArrayHandoffPlan | None, owner_path: str) -> str: + """Render a prototype function result's shape, which must be explicit.""" if array is None or array.rank is None: raise ValueError(f"Prototype value {owner_path!r} has no concrete shape") + runtime = [expression for expression in array.shape if expression in RUNTIME_EXTENT_MARKERS] + if runtime: + raise ValueError( + f"Prototype result {owner_path!r} has runtime extents {runtime} " + "and cannot be spelled as a native function result" + ) return ", ".join(render_declaration_extent(expression, {}, target="fortran") for expression in array.shape) def _procedure_prototype_imports( diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 9362a07be..c2aa379de 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -3941,7 +3941,10 @@ def _parse_declaration_left( return declaration, split_csv((decl.group("attrs") or "").strip().lstrip(", ")) if re.match(r"^procedure\s*\(", left, re.IGNORECASE): procm = _REGEX["procedure_dummy"].match(left) - iface = procm.group("iface").lower() if procm else None + # The interface name is a user-visible symbol that reaches the + # generated .pyi contract, so it keeps its declared spelling; + # every comparison against it normalizes case at the comparison. + iface = procm.group("iface") if procm else None return self._new_declaration("procedure", iface), split_csv( (procm.group("attrs") if procm else "").strip().lstrip(", ") ) @@ -4043,7 +4046,7 @@ def _store_procedure_declaration( filename=filename, code="PARSE_INTERNAL_STATE", ) - if declaration.base_type == "procedure" and declaration.kind in proc_state.imports: + if declaration.base_type == "procedure" and self._scope_key(declaration.kind or "") in proc_state.imports: declaration.kind = "" for normalized_name, shape, _initializer, entity_declaration in self._declaration_entities( right, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index f5850d744..9741cc561 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -2329,19 +2329,34 @@ def _callback_scalar_projection_diagnostics( transfer: CallbackTransferPlan, position: int, ) -> tuple[WrapperPlanDiagnostic, ...]: - """Require every primitive scalar callback transfer to use its value projection.""" - if position < 0 or transfer.object_kind is not ObjectKind.SCALAR or transfer.rank != 0: + """Require every primitive scalar callback transfer to use a completed projection. + + A rank-zero primitive dummy is projected either as an independent value + or, when the native caller reads it back, as rank-zero storage the + callable writes through. Any other pairing of projection, ABI and copy + direction means completed policy and the plan disagree. + """ + if position < 0 or transfer.rank != 0: + return () + copies = { + CallbackTransferAction.COPY_IN, + CallbackTransferAction.COPY_OUT, + CallbackTransferAction.COPY_IN_OUT, + } + if transfer.object_kind is ObjectKind.SCALAR: + valid = ( + transfer.python_action is PythonBarrierAction.SCALAR_VALUE + and transfer.abi in {CallbackABIKind.VALUE, CallbackABIKind.REFERENCE} + and transfer.adapter_action in copies + ) + elif transfer.object_kind is ObjectKind.NUMPY_ARRAY: + valid = ( + transfer.python_action is PythonBarrierAction.SCALAR_STORAGE + and transfer.abi is CallbackABIKind.REFERENCE + and transfer.adapter_action in copies + ) + else: return () - valid = ( - transfer.python_action is PythonBarrierAction.SCALAR_VALUE - and transfer.abi in {CallbackABIKind.VALUE, CallbackABIKind.REFERENCE} - and transfer.adapter_action - in { - CallbackTransferAction.COPY_IN, - CallbackTransferAction.COPY_OUT, - CallbackTransferAction.COPY_IN_OUT, - } - ) return ( () if valid diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 82d73340a..852164b9d 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -1574,6 +1574,13 @@ def _callback_transfer_blockers( ) if transfer.passed_by_value and transfer.rank > 0: blockers.append(f"callback argument {argument.name!r} cannot pass an array by value") + if _discards_callback_scalar_writeback(transfer): + # Python has no writable scalar, so a value projection cannot deliver + # anything back to the native caller that reads this dummy after the call. + blockers.append( + f"callback argument {argument.name!r} is intent({transfer.intent}) and cannot use the " + f"value spelling Addr({semantic_type.name}); use {semantic_type.name}[()] for writable storage" + ) if semantic_type.name == "String": if transfer.character_length is None or transfer.character_length <= 0: blockers.append(f"callback argument {argument.name!r} requires a fixed positive character length") @@ -1587,6 +1594,17 @@ def _callback_transfer_blockers( return tuple(blockers) +def _discards_callback_scalar_writeback(transfer: CallbackTransferPolicy) -> bool: + """Report whether a written-back scalar dummy was projected as an unwritable value.""" + return bool( + transfer.rank == 0 + and not transfer.passed_by_value + and transfer.intent is not None + and str(transfer.intent).casefold() in {"out", "inout"} + and transfer.python_action is PythonBarrierAction.SCALAR_VALUE + ) + + def _callback_result_policy( return_type: object, *, @@ -4373,6 +4391,12 @@ def _derived_argument_handoff_blockers( """Require the exact native type definition for a typed value call.""" if derived is None: return () + interface = argument.semantic_type.metadata.get(models.UNRESOLVED_PROCEDURE_INTERFACE_METADATA) + if interface is not None: + return ( + f"argument {argument.name!r} declares procedure interface {str(interface)!r}, " + "which no supplied source declares; add the module that declares it to the build inputs", + ) return _derived_type_definition_blockers(f"argument {argument.name!r}", derived, derived_types) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index b7f066ef9..678ff0447 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -51,6 +51,7 @@ PYTHON_VALUE_MUTABILITY_METADATA, PROTOTYPE_INTENT_METADATA, PROTOTYPE_REF_METADATA, + UNRESOLVED_PROCEDURE_INTERFACE_METADATA, RUNTIME_RELEASE_GIL_METADATA, HIDDEN_NATIVE_OUTPUT_METADATA, RUNTIME_STATUS_ERROR_METADATA, @@ -229,7 +230,13 @@ def _visit_SemanticType( if semantic_type.name == "Unknown" or semantic_type.dtype == "Unknown": raise ValueError("Cannot emit .pyi with unresolved semantic type 'Unknown'") array_descriptor = native_array_descriptor_kind(semantic_type) - if PROTOTYPE_REF_METADATA in semantic_type.metadata: + unresolved_interface = semantic_type.metadata.get(UNRESOLVED_PROCEDURE_INTERFACE_METADATA) + if unresolved_interface is not None: + # The declaration named an interface no supplied module declares. + # Spelling that name keeps the extracted contract self-consistent + # with the import already emitted for it. + text = str(unresolved_interface) + elif PROTOTYPE_REF_METADATA in semantic_type.metadata: text = semantic_type.name elif array_descriptor is not None: wrapper = "Allocatable" if array_descriptor == "allocatable" else "Pointer" diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 8d269ccfc..2737c9b70 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -72,6 +72,7 @@ PYTHON_STATIC_METADATA, PROTOTYPE_INTENT_METADATA, PROTOTYPE_REF_METADATA, + UNRESOLVED_PROCEDURE_INTERFACE_METADATA, SemanticArgument, SemanticArrayContract, SemanticClass, @@ -344,17 +345,27 @@ def _visit_FortranFile( parsed_file: FortranFile, *, standalone_module_name: str | None = None, + sibling_modules: Iterable[FortranModule] = (), ) -> list[SemanticModule]: """Convert every module and standalone procedure group in one file. The method first expands the wrapped-derived-type lookup from the file, then preserves parser module order. Standalone procedures are emitted - last as the requested synthetic module when present. + last as the requested synthetic module when present. ``sibling_modules`` + supplies modules parsed from other files so that an abstract interface + imported across files resolves the same way it does for a project. """ converter = self._with_additional_wrapped_types(self._wrapped_types_from_file(parsed_file)) converter = converter._with_additional_known_procedures(self._known_procedures_from_file(parsed_file)) converter = converter._with_additional_abstract_types(self._abstract_types_from_file(parsed_file)) - modules = [converter.visit(module) for module in parsed_file.modules] + known_modules = {item.name.casefold(): item for item in (*sibling_modules, *parsed_file.modules)} + modules = [ + converter.visit( + module, + callback_interfaces=self._imported_callback_interface_lookup(known_modules, module), + ) + for module in parsed_file.modules + ] if parsed_file.procedures: modules.append( converter.procedures_to_semantic_module( @@ -688,9 +699,23 @@ def _project_callback_interface_lookup( project: FortranProject, module: FortranModule, ) -> dict[str, FortranProcedureSignature]: - """Resolve abstract interfaces imported from another parsed module.""" + """Resolve abstract interfaces imported from another parsed project module.""" modules = {name.casefold(): item for name, item in project.modules.items()} modules.update({item.name.casefold(): item for parsed_file in project.files for item in parsed_file.modules}) + return cls._imported_callback_interface_lookup(modules, module) + + @classmethod + def _imported_callback_interface_lookup( + cls, + modules: dict[str, FortranModule], + module: FortranModule, + ) -> dict[str, FortranProcedureSignature]: + """Resolve abstract interfaces imported from another known module. + + The index is keyed by casefolded module name; an interface declared in + a module outside it stays unresolved, which later stages report against + the ``use`` that named it. + """ imported: dict[str, FortranProcedureSignature] = {} for module_name, mappings in module.uses.items(): source_module = modules.get(module_name.casefold()) @@ -725,7 +750,13 @@ def _callback_semantic_type( interface_name = str(arg.kind or arg.name) signature = callback_interfaces.get(interface_name.casefold()) if signature is None: - return self._convert_variable_type(arg, derived_type_context=derived_type_context) + semantic_type = self._convert_variable_type(arg, derived_type_context=derived_type_context) + if getattr(arg, "kind", None): + # The declaration named an interface that no supplied module + # declares, which later stages report against that name rather + # than against the opaque procedure type used as a placeholder. + semantic_type.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] = interface_name + return semantic_type context = self._procedure_derived_type_context(signature, derived_type_context) projected_arguments = list(signature.arguments) @@ -779,11 +810,18 @@ def _normalize_callback_reference_storage( callback_argument: SemanticArgument, source_argument: FortranArgument | FortranVariable, ) -> None: - """Make every non-value callback dummy a permissive reference contract.""" + """Make every non-value callback dummy a permissive reference contract. + + A dummy the native caller reads back after the call needs storage the + Python callable can write through. Python has no writable scalar, so + an ``out`` or ``inout`` primitive scalar records rank-zero storage + rather than the value contract used for a read-only dummy. + """ if getattr(source_argument, "pass_by_value", False): return semantic_type = callback_argument.semantic_type - if semantic_type.name == "String" and semantic_type.rank == 0: + written_back = FortranToIRConverter._is_written_back_callback_scalar(source_argument, semantic_type) + if written_back or (semantic_type.name == "String" and semantic_type.rank == 0): semantic_type.storage = SemanticStorageContract( kind="array", read_only=False, @@ -806,6 +844,20 @@ def _normalize_callback_reference_storage( semantic_type.storage.mutable = True semantic_type.ownership.mutable = True + @staticmethod + def _is_written_back_callback_scalar( + source_argument: FortranArgument | FortranVariable, + semantic_type: SemanticType, + ) -> bool: + """Report whether one primitive scalar callback dummy is read back by the caller.""" + intent = getattr(source_argument, "intent", None) + return bool( + intent is not None + and str(intent).casefold() in {"out", "inout"} + and int(semantic_type.rank or 0) == 0 + and semantic_type.name in SEMANTIC_SCALAR_TYPE_NAMES + ) + @staticmethod def _record_prototype_argument_intent( argument: SemanticArgument, @@ -3674,12 +3726,15 @@ def fortran_file_to_semantic_modules( wrapped_derived_types: Iterable[tuple[str, str]] | None = None, type_facts: dict[tuple[str, str | None], dict[str, object]] | None = None, assume_intent_in_scalars: bool = False, + sibling_modules: Iterable[FortranModule] = (), ) -> list[SemanticModule]: """Convert every module and standalone procedure group in one parsed file. Use this rather than the single-module helper when file-level procedures matter. Parser module ordering is retained, and ``standalone_module_name`` - controls the synthetic module used for top-level procedures. + controls the synthetic module used for top-level procedures. Pass + ``sibling_modules`` when other files were parsed alongside this one so an + abstract interface imported across files resolves. Example: >>> parsed = FortranFile(procedures=[FortranProcedureSignature(name="tick", kind="subroutine")]) @@ -3694,6 +3749,7 @@ def fortran_file_to_semantic_modules( ).visit( parsed_file, standalone_module_name=standalone_module_name, + sibling_modules=sibling_modules, ) diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 05b81507f..063cef5ae 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -18,6 +18,7 @@ EXTERNAL_TYPE_REF_METADATA = "external_type_ref" PROTOTYPE_REF_METADATA = "prototype_ref" PROTOTYPE_INTENT_METADATA = "prototype_intent" +UNRESOLVED_PROCEDURE_INTERFACE_METADATA = "unresolved_procedure_interface" INTERNAL_MODULE_VARIABLE_ACCESS_METADATA = "internal_module_variable_access" INTERNAL_MODULE_VARIABLE_NAME_METADATA = "internal_module_variable_name" INTERNAL_NATIVE_ARRAY_HANDLE_OPERATION_METADATA = "internal_native_array_handle_operation" diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index f853262c3..924d24cde 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -21,6 +21,7 @@ from dataclasses import dataclass __all__ = ( + "RUNTIME_EXTENT_MARKERS", "ArrayExpressionSource", "DeclarationExpressionCall", "ResolvedDeclarationExtent", @@ -41,7 +42,11 @@ ) -_RUNTIME_DIMENSIONS = frozenset({":", "::Strided", "...", "Flat"}) +# A runtime extent has a concrete rank but no compile-time bound, so a backend +# spells it from the descriptor it is handed rather than from the expression. +RUNTIME_EXTENT_MARKERS = frozenset({":", "::Strided", "Flat"}) +_ASSUMED_RANK_MARKER = "..." +_RUNTIME_DIMENSIONS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} _FORTRAN_RELATIONAL_OPERATORS = { ".eq.": "==", ".ne.": "!=", diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 527aa537d..a392b9967 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -69,7 +69,13 @@ def test_callback_policy_completes_value_default_and_explicit_reference_before_p CallbackTransferAction.COPY_OUT, CallbackTransferAction.COPY_IN, ) - assert tuple(transfer.python_action for transfer in scalar.arguments) == (PythonBarrierAction.SCALAR_VALUE,) * 3 + # A dummy the native caller reads back needs storage Python can write + # through; a copy-in-only dummy keeps the independent value projection. + assert tuple(transfer.python_action for transfer in scalar.arguments) == ( + PythonBarrierAction.SCALAR_STORAGE, + PythonBarrierAction.SCALAR_STORAGE, + PythonBarrierAction.SCALAR_VALUE, + ) array = policies["apply_array_storage_callback"].arguments[0].callback assert array.arguments[0].abi is CallbackABIKind.REFERENCE @@ -157,7 +163,9 @@ def test_callback_plan_edits_fail_central_validation_before_backend_emission(edi callback.arguments[1].extent_roles = () elif edit == "scalar_projection": callback = _callback_argument(plan, "apply_scalar_storage_callback").callback - callback.arguments[0].python_action = PythonBarrierAction.SCALAR_STORAGE + # A rank-zero storage transfer cannot claim the value projection: an + # immutable value cannot deliver a write back to the native caller. + callback.arguments[0].python_action = PythonBarrierAction.SCALAR_VALUE elif edit == "result": callback = _callback_argument(plan, "apply_value_callback").callback callback.result.action = CallbackResultAction.RETURN_VOID @@ -245,3 +253,61 @@ def test_optional_callback_retains_one_exact_policy_blocker(): with pytest.raises(ValueError, match="unsupported optional callback"): WrapperPlanner().build(module) + + +def test_runtime_callback_extents_lower_to_assumed_shape_dummies_and_measured_copies(): + """Codegen spells a runtime extent instead of leaking the plan's marker. + + A runtime extent reaches the bridge as a public marker rather than an + expression, so the dummy takes the caller's descriptor and the contiguous + copy that backs ``c_loc`` is measured from that dummy. + """ + module = pyi_file_to_semantic_module(ARRAY_CONTRACT, module_name="fcallback_array_f90") + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + + callback = _callback_argument(plan, "apply_assumed_shape").callback + assert [transfer.array.shape for transfer in callback.arguments] == [("::Strided",), ("::Strided",)] + + _, bridge = _sources(plan) + assert "::Strided" not in bridge + assert "real(c_double), intent(in), dimension(:) :: values" in bridge + assert "real(c_double), target, dimension(size(values, 1)) :: values_callback_storage" in bridge + assert "real(c_double), intent(out), dimension(:) :: doubled" in bridge + assert "real(c_double), target, dimension(size(doubled, 1)) :: doubled_callback_storage" in bridge + + +def test_rank_zero_callback_storage_lowers_to_a_direction_correct_native_view(): + """Rank-zero storage aliases native memory instead of copying a value. + + Writeability follows the completed transfer direction, so only an ``out`` + or ``inout`` dummy can be written through. + """ + module = pyi_text_to_semantic_module( + """ +from prik.contracts import Float64, In, InOut, Out, prototype + +@prototype +def directions_callback( + read_value: In(Float64[()]), + update_value: InOut(Float64[()]), + write_value: Out(Float64[()]) +) -> None: ... + +def apply_directions(callback: directions_callback) -> None: ... +""", + module_name="callback_scalar_storage", + ) + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + + callback = _callback_argument(plan, "apply_directions").callback + assert [transfer.python_action for transfer in callback.arguments] == [PythonBarrierAction.SCALAR_STORAGE] * 3 + assert [transfer.abi for transfer in callback.arguments] == [CallbackABIKind.REFERENCE] * 3 + + c_source, _bridge = _sources(plan) + read_only = "PyArray_New(&PyArray_Type, 0, NULL, NPY_FLOAT64, NULL, read_value_data, 0, " + assert f"{read_only}NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED, NULL)" in c_source + for parameter in ("update_value", "write_value"): + writable = f"PyArray_New(&PyArray_Type, 0, NULL, NPY_FLOAT64, NULL, {parameter}_data, 0, " + assert f"{writable}NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" in c_source diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi index b6895f49a..34a81ec5f 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi @@ -18,8 +18,8 @@ def value_callback( @prototype def scalar_storage_callback( - value: InOut(Addr(Float64)), - output: Out(Addr(Float64)), + value: InOut(Float64[()]), + output: Out(Float64[()]), missing: Addr(Float64) ) -> None: ... diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/fcallback_array_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/fcallback_array_f90.pyi index 5623f4aad..c2751e0af 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/fcallback_array_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/fcallback_array_f90.pyi @@ -1,4 +1,4 @@ -from prik.contracts import Addr, Arg, Float64, In, Int32, native_call, prototype +from prik.contracts import Addr, Arg, Float64, In, Int32, Out, native_call, prototype @prototype def reduce_callback( @@ -12,6 +12,12 @@ def transform_callback( values: In(Float64[count]) ) -> Float64[count]: ... +@prototype +def assumed_shape_callback( + values: In(Float64[::]), + doubled: Out(Float64[::]) +) -> None: ... + @native_call([Arg(0), Addr(Arg(1)), Arg(2)]) def apply_reduce( callback: reduce_callback, @@ -26,3 +32,9 @@ def apply_transform( values: Float64[count], output: Float64[count] ) -> None: ... + +def apply_assumed_shape( + callback: assumed_shape_callback, + values: Float64[::], + doubled: Float64[::] +) -> None: ... diff --git a/tests/fortran/callbacks/end_to_end/fixtures/native/fcallback_array_f90.f90 b/tests/fortran/callbacks/end_to_end/fixtures/native/fcallback_array_f90.f90 index 28e673c31..a3b003125 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/native/fcallback_array_f90.f90 +++ b/tests/fortran/callbacks/end_to_end/fixtures/native/fcallback_array_f90.f90 @@ -14,6 +14,11 @@ function transform_callback(count, values) result(output) real(8), intent(in) :: values(count) real(8) :: output(count) end function transform_callback + + subroutine assumed_shape_callback(values, doubled) + real(8), intent(in) :: values(:) + real(8), intent(out) :: doubled(:) + end subroutine assumed_shape_callback end interface contains @@ -33,4 +38,12 @@ subroutine apply_transform(callback, count, values, output) output = callback(count, values) end subroutine apply_transform + + subroutine apply_assumed_shape(callback, values, doubled) + procedure(assumed_shape_callback) :: callback + real(8), intent(in) :: values(:) + real(8), intent(out) :: doubled(:) + + call callback(values, doubled) + end subroutine apply_assumed_shape end module fcallback_array_f90 diff --git a/tests/fortran/callbacks/end_to_end/test_array_callbacks.py b/tests/fortran/callbacks/end_to_end/test_array_callbacks.py index ac7ee8677..82a12c0fa 100644 --- a/tests/fortran/callbacks/end_to_end/test_array_callbacks.py +++ b/tests/fortran/callbacks/end_to_end/test_array_callbacks.py @@ -40,3 +40,32 @@ def test_immediate_dummy_procedure_converts_array_arguments_and_results( ) assert result is None np.testing.assert_array_equal(transformed, np.array([2.0, 4.0, 6.0], dtype=np.float64)) + + +def test_assumed_shape_callback_arrays_cross_the_boundary_as_contiguous_copies( + pyi_parity_build_mode: str, + tmp_path: Path, +): + """An assumed-shape callback dummy carries its extent from the native descriptor.""" + module = _build_source_or_generated_pyi_and_import( + CALLBACK_ARRAY_F90_SOURCE, + tmp_path, + { + "bind_c_fcallback_array_f90_wrapper.f90", + "fcallback_array_f90_wrapper.c", + "fcallback_array_f90_wrapper.h", + }, + CONTRACT_FIXTURES / "fcallback_array_f90", + pyi_parity_build_mode, + ) + values = np.asfortranarray(np.array([1.5, 2.5, 3.5, 4.5], dtype=np.float64)) + doubled = np.zeros(4, dtype=np.float64) + seen = [] + + def double(data, output): + seen.append(np.array(data)) + output[...] = data * 2.0 + + assert module.apply_assumed_shape(double, values, doubled) is None + np.testing.assert_array_equal(seen[0], values) + np.testing.assert_array_equal(doubled, values * 2.0) diff --git a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py new file mode 100644 index 000000000..e83234514 --- /dev/null +++ b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py @@ -0,0 +1,168 @@ +"""Rank-zero callback storage: an edited contract writes through native memory.""" + +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import ( + _build_inline_pyi_contract_module, + _build_source_and_import, +) + +pytestmark = pytest.mark.fortran_end_to_end + +SOURCE = """ +module fcallback_scalar_storage_f90 + implicit none + + abstract interface + subroutine directions_callback(read_value, update_value, write_value) + real(8), intent(in) :: read_value + real(8), intent(inout) :: update_value + real(8), intent(out) :: write_value + end subroutine directions_callback + end interface + +contains + subroutine apply_directions(callback, read_value, update_value, write_value) + procedure(directions_callback) :: callback + real(8), intent(in) :: read_value + real(8), intent(inout) :: update_value + real(8), intent(out) :: write_value + + call callback(read_value, update_value, write_value) + end subroutine apply_directions +end module fcallback_scalar_storage_f90 +""" + +CONTRACT = """ +from prik.contracts import Addr, Arg, Float64, In, InOut, Out, Return, Returns, native_call, prototype + +@prototype +def directions_callback( + read_value: In(Float64[()]), + update_value: InOut(Float64[()]), + write_value: Out(Float64[()]) +) -> None: ... + +@native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2)), Return('write_value', 1)]) +def apply_directions( + callback: directions_callback, + read_value: Float64, + update_value: Float64 +) -> tuple[Returns["update_value", Float64], Float64]: ... +""" + + +def test_rank_zero_callback_storage_writes_through_to_the_native_caller(tmp_path: Path): + """A rank-zero storage dummy exposes native memory with direction-correct access. + + The default `Addr(T)` spelling hands Python an independent value, so a + contract that needs an `out` or `inout` callback dummy to reach the native + caller asks for storage instead. + """ + module, _result = _build_inline_pyi_contract_module( + tmp_path, + module_name="fcallback_scalar_storage_f90", + source_text=SOURCE, + contract_text=CONTRACT, + ) + observed = {} + + def callback(read_value, update_value, write_value): + observed["read_writeable"] = read_value.flags.writeable + observed["update_writeable"] = update_value.flags.writeable + observed["write_writeable"] = write_value.flags.writeable + observed["read"] = float(read_value) + observed["update_in"] = float(update_value) + update_value[...] = float(update_value) * 10.0 + write_value[...] = float(read_value) + float(update_value) + + updated, written = module.apply_directions(callback, np.float64(3.0), np.float64(4.0)) + + assert observed == { + "read_writeable": False, + "update_writeable": True, + "write_writeable": True, + "read": 3.0, + "update_in": 4.0, + } + assert updated == np.float64(40.0) + assert written == np.float64(43.0) + + +SOURCE_DEFAULT = """ +module fcallback_default_storage_f90 + implicit none + + abstract interface + subroutine objective_callback(x, f) + real(8), intent(in) :: x(:) + real(8), intent(out) :: f + end subroutine objective_callback + end interface + +contains + subroutine evaluate(calfun, x, total) + procedure(objective_callback) :: calfun + real(8), intent(in) :: x(:) + real(8), intent(out) :: total + + call calfun(x, total) + end subroutine evaluate +end module fcallback_default_storage_f90 +""" + + +def test_out_scalar_callback_writes_back_without_editing_the_contract(tmp_path: Path): + """Wrapping Fortran source directly produces a callback that can answer. + + The generated default must be the spelling that works: an `intent(out)` + scalar reaches Python as writable storage, so the value the callable + computes reaches the native caller with no contract edit. + """ + source = tmp_path / "fcallback_default_storage_f90.f90" + source.write_text(SOURCE_DEFAULT, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_fcallback_default_storage_f90_wrapper.f90", + "fcallback_default_storage_f90_wrapper.c", + "fcallback_default_storage_f90_wrapper.h", + }, + ) + + def objective(x): + return float(np.sum(x * x)) + + def objective_prik(x, f): + f[...] = objective(x) + + assert module.evaluate(objective_prik, np.array([1.0, 2.0, 3.0])) == np.float64(14.0) + + +def test_callback_docstring_states_the_callable_signature_and_write_through(tmp_path: Path): + """The docstring is the only callback description in the source-only workflow. + + Guessing a callback signature wrong is fatal at the callback boundary, so + `help()` must state the arity, direction, and how an output is delivered. + """ + source = tmp_path / "fcallback_default_storage_f90.f90" + source.write_text(SOURCE_DEFAULT, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_fcallback_default_storage_f90_wrapper.f90", + "fcallback_default_storage_f90_wrapper.c", + "fcallback_default_storage_f90_wrapper.h", + }, + ) + documentation = module.evaluate.__doc__ + + assert "Called as: calfun(x, f) -> None" in documentation + assert "x : ndarray[float64], intent(in)" in documentation + assert "f : ndarray[float64], intent(out); assign through it (f[...] = value)" in documentation + assert "An exception or an invalid return value terminates the process." in documentation diff --git a/tests/fortran/callbacks/policy/test_callback_policy.py b/tests/fortran/callbacks/policy/test_callback_policy.py index b069f2936..fbac5728a 100644 --- a/tests/fortran/callbacks/policy/test_callback_policy.py +++ b/tests/fortran/callbacks/policy/test_callback_policy.py @@ -11,6 +11,7 @@ RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA, ) from prik.policy.completion import complete_semantic_policies +from prik.policy.ownership import PythonBarrierAction from prik.policy.models import ( CallbackABIKind, CallbackTransferAction, @@ -83,3 +84,109 @@ def apply(callback: callback_shape) -> None: ... assert isinstance(policy, FunctionWrapperPolicy) assert policy.supported is False assert blocker in policy.blockers + + +def test_procedure_interface_from_an_unsupplied_module_is_blocked_by_name(): + """A named interface no input declares is reported against that name. + + Without the module that declares it the dummy has no signature, so the + diagnostic must name the interface the declaration asked for rather than + the opaque placeholder type it fell back to. + """ + source = """ +module solver_mod + use, non_intrinsic :: pintrf_mod, only : OBJ + implicit none +contains + subroutine minimize(calfun, x) + procedure(OBJ) :: calfun + real(8), intent(in) :: x + end subroutine minimize +end module solver_mod +""" + parsed = parse_fortran_project({"solver.f90": source}) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="solver_mod") + complete_semantic_policies(module) + + policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] + assert policy.supported is False + assert ( + "argument 'calfun' declares procedure interface 'OBJ', which no supplied source declares; " + "add the module that declares it to the build inputs" in policy.blockers + ) + + +def test_written_back_callback_scalars_default_to_rank_zero_storage(): + """A dummy the native caller reads back is projected as writable storage. + + Python has no writable scalar, so an out or inout primitive scalar must + reach the callable as rank-zero storage; a copy-in-only dummy keeps the + independent value projection. + """ + module = _source_semantic_module("fcallback_all_f90.f90", module_name="fcallback_all_f90") + function = next(item for item in module.functions if item.name == "apply_scalar_storage_callback") + policy = completed_function_wrapper_policy(function) + transfers = policy.arguments[0].callback.arguments + + assert [transfer.intent for transfer in transfers] == ["inout", "out", None] + assert [transfer.python_action for transfer in transfers] == [ + PythonBarrierAction.SCALAR_STORAGE, + PythonBarrierAction.SCALAR_STORAGE, + PythonBarrierAction.SCALAR_VALUE, + ] + assert policy.supported is True + + +@pytest.mark.parametrize( + ("prototype", "blocker"), + [ + ( + "def callback_shape(value: Out(Addr(Float64))) -> None: ...", + "callback argument 'value' is intent(out) and cannot use the value spelling " + "Addr(Float64); use Float64[()] for writable storage", + ), + ( + "def callback_shape(value: InOut(Addr(Int32))) -> None: ...", + "callback argument 'value' is intent(inout) and cannot use the value spelling " + "Addr(Int32); use Int32[()] for writable storage", + ), + ], +) +def test_value_spelling_is_blocked_for_written_back_callback_scalars(prototype: str, blocker: str): + """An out or inout dummy spelled as a value would silently discard the write.""" + module = parse_pyi_text( + f""" +@prototype +{prototype} + +def apply(callback: callback_shape) -> None: ... +""", + module_name="discarded_callback_writeback", + ) + + complete_semantic_policies(module) + + policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] + assert policy.supported is False + assert blocker in policy.blockers + + +def test_read_only_callback_scalars_keep_the_value_spelling(): + """An in dummy is never read back, so the value projection stays valid.""" + module = parse_pyi_text( + """ +@prototype +def callback_shape(value: In(Addr(Float64))) -> None: ... + +def apply(callback: callback_shape) -> None: ... +""", + module_name="read_only_callback_scalar", + ) + + complete_semantic_policies(module) + + policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] + assert policy.supported is True + assert policy.arguments[0].callback.arguments[0].python_action is PythonBarrierAction.SCALAR_VALUE diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 30de6fe85..cfdce8adb 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -3,6 +3,7 @@ from prik.parsers.fortran import parse_fortran_project from prik.printers import emit_module from prik.semantics.fortran2ir import FortranToIRConverter +from prik.semantics.models import UNRESOLVED_PROCEDURE_INTERFACE_METADATA from prik.semantics.native_contract import native_contract_issues from tests.fortran._support.semantic_conversion import get_function from prik.parsers.fortran import parse_fortran_file as parse_fortran_source @@ -194,3 +195,69 @@ def test_duplicate_interface_signatures_emit_one_named_callback_prototype(): module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) assert [prototype.name for prototype in module.prototypes] == ["callback"] + + +def test_imported_abstract_interface_resolves_across_files_and_keeps_its_declared_name(): + """A `procedure(OBJ)` dummy resolves against the module that declares OBJ. + + The interface name reaches the generated contract as a public symbol, so + the declaration keeps the spelling the interface was declared with rather + than the casefolded key used to match it. + """ + interface_source = """ +module pintrf_mod + implicit none + private + public :: OBJ + + abstract interface + subroutine OBJ(x, f) + implicit none + real(8), intent(in) :: x(:) + real(8), intent(out) :: f + end subroutine OBJ + end interface +end module pintrf_mod +""" + solver_source = """ +module solver_mod + use, non_intrinsic :: pintrf_mod, only : OBJ + implicit none +contains + subroutine minimize(calfun, x, f) + procedure(OBJ) :: calfun + real(8), intent(in) :: x(:) + real(8), intent(out) :: f + call calfun(x, f) + end subroutine minimize +end module solver_mod +""" + project = parse_fortran_project({"pintrf.f90": interface_source, "solver.f90": solver_source}) + modules = {module.name: module for module in FortranToIRConverter().visit(project)} + + callback = get_function(modules["solver_mod"], "minimize").arguments[0].semantic_type + assert callback.name == "OBJ" + assert callback.storage is not None and callback.storage.kind == "callback" + assert [argument.name for argument in callback.metadata["callback_arguments"]] == ["x", "f"] + assert callback.metadata["arguments"][0].shape == ["::Strided"] + assert callback.metadata["return"].name == "None" + + +def test_named_but_undeclared_procedure_interface_is_recorded_for_diagnosis(): + """An unresolved `procedure(OBJ)` keeps the name so later stages can report it.""" + source = """ +module solver_mod + use, non_intrinsic :: pintrf_mod, only : OBJ + implicit none +contains + subroutine minimize(calfun, x) + procedure(OBJ) :: calfun + real(8), intent(in) :: x + end subroutine minimize +end module solver_mod +""" + + module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + + callback = get_function(module, "minimize").arguments[0].semantic_type + assert callback.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] == "OBJ" From 195e32b08235a490b227fafccefe4d35cef77f8c Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 00:23:02 +0100 Subject: [PATCH 02/24] Treat an undeclared callback scalar intent as conservatively writable Fortran permits a dummy with no declared INTENT to be both read and modified, but a primitive scalar callback dummy without one was projected as an independent value and copied in only, so a write by the Python callable was discarded. Semantic normalization now records rank-zero storage for such a dummy, and the callback transfer direction follows that completed storage rather than re-deriving copy-in from the absent intent. The declaration itself is unchanged: no intent is synthesized into the semantic origin or the generated Fortran interface, so the contract records the absence by carrying no direction wrapper. real(8), intent(in) :: f -> f: In(Addr(Float64)) copy-in real(8), intent(out) :: f -> f: Out(Float64[()]) copy-out real(8), intent(inout) :: f -> f: InOut(Float64[()]) copy-in/out real(8) :: f -> f: Float64[()] copy-in/out --assume-intent-in-scalars continues to elect which default an undeclared intent receives, narrowing that last row to the input-only projection without giving the dummy a direction it never declared. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 12 ++- docs/user/guide/callbacks.md | 25 ++++-- prik/policy/construction.py | 20 +++-- prik/semantics/fortran2ir.py | 33 ++++---- .../codegen/test_callback_planning.py | 13 ++- .../fcallback_all_f90/fcallback_all_f90.pyi | 2 +- .../test_callback_scalar_storage.py | 84 ++++++++++++++++++- .../callbacks/policy/test_callback_policy.py | 44 +++++++--- .../test_fortran_callback_semantics.py | 4 +- 9 files changed, 183 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8d6ce49..b9e425af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ release tags add a leading `v` to the package version. ## Unreleased -- An `intent(out)` or `intent(inout)` primitive scalar in a callback prototype - now reaches Python as rank-zero storage (`Out(Float64[()])`) instead of an - independent value, so the value the callback computes reaches the native - caller. Python has no writable scalar, so the previous `Out(Addr(T))` spelling +- A primitive scalar callback dummy the callee may write now reaches Python as + rank-zero storage (`Out(Float64[()])`) instead of an independent value, so + the value the callback computes reaches the native caller. This covers + `intent(out)` and `intent(inout)`, and also a dummy with no declared + `intent`, which Fortran permits the callee to modify — that case keeps its + missing direction in the contract as a bare `Float64[()]` rather than gaining + a synthesized one. `--assume-intent-in-scalars` elects the input-only default + for it instead. Python has no writable scalar, so the previous `Out(Addr(T))` spelling silently discarded the write; it is now a policy error naming the replacement. A prototype still mirrors the native argument list — edit it with `@native_call` to project an output into the callable's return value instead. diff --git a/docs/user/guide/callbacks.md b/docs/user/guide/callbacks.md index 779d8a05b..6dcf8dd5a 100644 --- a/docs/user/guide/callbacks.md +++ b/docs/user/guide/callbacks.md @@ -234,13 +234,21 @@ For scalar arguments, choose the spelling from the Fortran callback dummy: Both forms call Python with an independent `np.float64` scalar. The difference is the native calling convention PRIK must match. -A dummy the native caller reads back after the call is different: PRIK generates -rank-zero storage for it, because Python has no writable scalar. +A dummy the callee may write is different: PRIK generates rank-zero storage for +it, because Python has no writable scalar. A dummy with no declared `intent` +counts here — Fortran lets the callee both read and modify it, so PRIK is +conservative and the contract records the missing direction by carrying no +wrapper: -| Fortran callback dummy | Generated prototype | -| --- | --- | -| `real(8), intent(out) :: f` | `f: Out(Float64[()])` | -| `real(8), intent(inout) :: f` | `f: InOut(Float64[()])` | +| Fortran callback dummy | Generated prototype | Callback may | +| --- | --- | --- | +| `real(8), intent(in) :: f` | `f: In(Addr(Float64))` | read | +| `real(8), intent(out) :: f` | `f: Out(Float64[()])` | write | +| `real(8), intent(inout) :: f` | `f: InOut(Float64[()])` | read and write | +| `real(8) :: f` | `f: Float64[()]` | read and write | + +Pass `--assume-intent-in-scalars` to treat an undeclared scalar as input-only +instead; the dummy still records no direction, it simply stops being writable. Python receives a rank-zero NumPy view of the native storage. Assign through it; rebinding the name changes nothing the native caller will read: @@ -283,8 +291,9 @@ derived-type callback dummy declared with the Fortran `value` attribute. - Return the exact NumPy scalar type when PRIK expects a scalar callback result. - Primitive scalar callback arguments arrive as independent NumPy scalar values, whether the native dummy is `value` or reference. -- Primitive scalar `in` arguments arrive as independent values; `out` and - `inout` arguments arrive as rank-zero storage you assign through. +- Primitive scalar `in` arguments arrive as independent values. Arguments the + callee may write — `out`, `inout`, or no declared `intent` — arrive as + rank-zero storage you assign through. - Arrays and derived-type arguments can expose live native state; copy data you need after the wrapped call returns. diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 852164b9d..b8d40f6e0 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -1542,19 +1542,27 @@ def _callback_abi_kind( def _callback_adapter_action( argument: models.SemanticArgument, ) -> CallbackTransferAction: - """Select callback copy direction from the prototype's exact dummy intent.""" + """Select callback copy direction from the prototype's completed dummy contract. + + A declared ``intent`` names the direction outright. With none declared the + callee may both read and modify the dummy, so the direction follows the + completed storage: writable rank-zero storage copies in and out, while a + value projection is input-only. + """ semantic_type = argument.semantic_type intent = argument.origin.metadata.get(models.PROTOTYPE_INTENT_METADATA) if intent == "out": return CallbackTransferAction.COPY_OUT if intent == "inout": return CallbackTransferAction.COPY_IN_OUT - if ( - intent == "in" - or bool(argument.origin.metadata.get("value")) - or (semantic_type.name in _PLAN_PRIMITIVE_SCALAR_TYPES and int(semantic_type.rank or 0) == 0) - ): + if intent == "in" or bool(argument.origin.metadata.get("value")): return CallbackTransferAction.COPY_IN + if semantic_type.name in _PLAN_PRIMITIVE_SCALAR_TYPES and int(semantic_type.rank or 0) == 0: + return ( + CallbackTransferAction.COPY_IN_OUT + if _is_scalar_storage_type(semantic_type) + else CallbackTransferAction.COPY_IN + ) return CallbackTransferAction.COPY_IN_OUT diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 2737c9b70..ccdf2ab0e 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -805,22 +805,22 @@ def _callback_semantic_type( ), ) - @staticmethod def _normalize_callback_reference_storage( + self, callback_argument: SemanticArgument, source_argument: FortranArgument | FortranVariable, ) -> None: """Make every non-value callback dummy a permissive reference contract. - A dummy the native caller reads back after the call needs storage the - Python callable can write through. Python has no writable scalar, so - an ``out`` or ``inout`` primitive scalar records rank-zero storage - rather than the value contract used for a read-only dummy. + A dummy the callee may write needs storage the Python callable can + write through. Python has no writable scalar, so such a primitive + scalar records rank-zero storage rather than the value contract used + for a dummy the callee only reads. """ if getattr(source_argument, "pass_by_value", False): return semantic_type = callback_argument.semantic_type - written_back = FortranToIRConverter._is_written_back_callback_scalar(source_argument, semantic_type) + written_back = self._is_written_back_callback_scalar(source_argument, semantic_type) if written_back or (semantic_type.name == "String" and semantic_type.rank == 0): semantic_type.storage = SemanticStorageContract( kind="array", @@ -844,19 +844,24 @@ def _normalize_callback_reference_storage( semantic_type.storage.mutable = True semantic_type.ownership.mutable = True - @staticmethod def _is_written_back_callback_scalar( + self, source_argument: FortranArgument | FortranVariable, semantic_type: SemanticType, ) -> bool: - """Report whether one primitive scalar callback dummy is read back by the caller.""" + """Report whether the callee may write one primitive scalar callback dummy. + + Fortran permits a dummy with no declared ``intent`` to be both read and + modified, so an undeclared direction is conservatively writable. Only + ``assume_intent_in_scalars`` elects the input-only default for it; the + declaration itself keeps no intent either way. + """ + if int(semantic_type.rank or 0) != 0 or semantic_type.name not in SEMANTIC_SCALAR_TYPE_NAMES: + return False intent = getattr(source_argument, "intent", None) - return bool( - intent is not None - and str(intent).casefold() in {"out", "inout"} - and int(semantic_type.rank or 0) == 0 - and semantic_type.name in SEMANTIC_SCALAR_TYPE_NAMES - ) + if intent is None: + return not self.assume_intent_in_scalars + return str(intent).casefold() in {"out", "inout"} @staticmethod def _record_prototype_argument_intent( diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index a392b9967..874af1888 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -64,18 +64,15 @@ def test_callback_policy_completes_value_default_and_explicit_reference_before_p assert scalar.thread_action is CallbackThreadAction.REQUIRE_ENTERING_THREAD assert scalar.gil_actions == (CallbackGILAction.ACQUIRE_GIL, CallbackGILAction.RELEASE_GIL) assert tuple(transfer.abi for transfer in scalar.arguments) == (CallbackABIKind.REFERENCE,) * 3 + # An undeclared intent permits the callee to read and modify the dummy, so + # it copies both ways rather than defaulting to copy-in. assert tuple(transfer.adapter_action for transfer in scalar.arguments) == ( CallbackTransferAction.COPY_IN_OUT, CallbackTransferAction.COPY_OUT, - CallbackTransferAction.COPY_IN, - ) - # A dummy the native caller reads back needs storage Python can write - # through; a copy-in-only dummy keeps the independent value projection. - assert tuple(transfer.python_action for transfer in scalar.arguments) == ( - PythonBarrierAction.SCALAR_STORAGE, - PythonBarrierAction.SCALAR_STORAGE, - PythonBarrierAction.SCALAR_VALUE, + CallbackTransferAction.COPY_IN_OUT, ) + # Every dummy the callee may write needs storage Python can write through. + assert tuple(transfer.python_action for transfer in scalar.arguments) == (PythonBarrierAction.SCALAR_STORAGE,) * 3 array = policies["apply_array_storage_callback"].arguments[0].callback assert array.arguments[0].abi is CallbackABIKind.REFERENCE diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi index 34a81ec5f..863dfa6fc 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/fcallback_all_f90.pyi @@ -20,7 +20,7 @@ def value_callback( def scalar_storage_callback( value: InOut(Float64[()]), output: Out(Float64[()]), - missing: Addr(Float64) + missing: Float64[()] ) -> None: ... @prototype diff --git a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py index e83234514..d9cee3fc9 100644 --- a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py +++ b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py @@ -1,5 +1,7 @@ -"""Rank-zero callback storage: an edited contract writes through native memory.""" +"""Rank-zero callback storage: writable scalar dummies reach native memory.""" +import subprocess +import sys from pathlib import Path import numpy as np @@ -166,3 +168,83 @@ def test_callback_docstring_states_the_callable_signature_and_write_through(tmp_ assert "x : ndarray[float64], intent(in)" in documentation assert "f : ndarray[float64], intent(out); assign through it (f[...] = value)" in documentation assert "An exception or an invalid return value terminates the process." in documentation + + +SOURCE_UNDECLARED = """ +module fcallback_undeclared_intent_f90 + implicit none + + abstract interface + subroutine tweak_callback(value) + real(8) :: value + end subroutine tweak_callback + end interface + +contains + subroutine drive(callback, seed, result) + procedure(tweak_callback) :: callback + real(8), intent(in) :: seed + real(8), intent(out) :: result + + result = seed + call callback(result) + end subroutine drive +end module fcallback_undeclared_intent_f90 +""" + + +def _undeclared_intent_module(tmp_path: Path): + source = tmp_path / "fcallback_undeclared_intent_f90.f90" + source.write_text(SOURCE_UNDECLARED, encoding="utf-8") + return _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_fcallback_undeclared_intent_f90_wrapper.f90", + "fcallback_undeclared_intent_f90_wrapper.c", + "fcallback_undeclared_intent_f90_wrapper.h", + }, + ) + + +def test_callback_scalar_without_declared_intent_is_read_and_written(tmp_path: Path): + """An undeclared ``intent`` is conservatively both read and written. + + Fortran permits the callee to modify such a dummy, so the callable must + observe the incoming value and see its own write reach the native caller. + """ + module = _undeclared_intent_module(tmp_path) + observed = [] + + def tweak(value): + observed.append(float(value)) + assert value.flags.writeable + value[...] = float(value) * 3.0 + + assert module.drive(tweak, np.float64(7.0)) == np.float64(21.0) + assert observed == [7.0] + + +def test_undeclared_intent_stays_undeclared_in_the_generated_contract(tmp_path: Path): + """The conservative transfer must not invent a direction the source lacks. + + The contract records the absent ``intent`` by carrying no direction + wrapper, and the generated interface body declares the dummy without one. + """ + source = tmp_path / "fcallback_undeclared_intent_f90.f90" + source.write_text(SOURCE_UNDECLARED, encoding="utf-8") + contracts = tmp_path / "contracts" + subprocess.run( + [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(contracts)], + check=True, + capture_output=True, + ) + contract = (contracts / "fcallback_undeclared_intent_f90.pyi").read_text(encoding="utf-8") + + assert "value: Float64[()]" in contract + assert "In(" not in contract and "Out(" not in contract and "InOut(" not in contract + + _undeclared_intent_module(tmp_path) + bridge = (tmp_path / "build" / "bind_c_fcallback_undeclared_intent_f90_wrapper.f90").read_text(encoding="utf-8") + assert "real(c_double) :: value" in bridge + assert "intent(inout) :: value" not in bridge diff --git a/tests/fortran/callbacks/policy/test_callback_policy.py b/tests/fortran/callbacks/policy/test_callback_policy.py index fbac5728a..6a7ed76b0 100644 --- a/tests/fortran/callbacks/policy/test_callback_policy.py +++ b/tests/fortran/callbacks/policy/test_callback_policy.py @@ -22,10 +22,10 @@ FIXTURES = Path(__file__).parents[1] / "end_to_end" / "fixtures" -def _source_semantic_module(filename: str, *, module_name: str): +def _source_semantic_module(filename: str, *, module_name: str, assume_intent_in_scalars: bool = False): source = FIXTURES / "native" / filename parsed = parse_fortran_project({str(source): _fortran_source_for_pipeline(source, PreprocessingConfig())}) - modules = fortran_project_to_semantic_modules(parsed) + modules = fortran_project_to_semantic_modules(parsed, assume_intent_in_scalars=assume_intent_in_scalars) _apply_source_python_exports(modules) module = _merge_wrapper_modules(modules, name=module_name) complete_semantic_policies(module) @@ -118,12 +118,13 @@ def test_procedure_interface_from_an_unsupplied_module_is_blocked_by_name(): ) -def test_written_back_callback_scalars_default_to_rank_zero_storage(): - """A dummy the native caller reads back is projected as writable storage. +def test_writable_callback_scalars_use_rank_zero_storage_without_synthesizing_intent(): + """Every dummy the callee may write is projected as writable storage. - Python has no writable scalar, so an out or inout primitive scalar must - reach the callable as rank-zero storage; a copy-in-only dummy keeps the - independent value projection. + Python has no writable scalar, so a dummy the native caller reads back must + reach the callable as rank-zero storage. An undeclared ``intent`` is + conservatively writable because Fortran permits the callee to modify it, + and the declaration keeps no intent of its own either way. """ module = _source_semantic_module("fcallback_all_f90.f90", module_name="fcallback_all_f90") function = next(item for item in module.functions if item.name == "apply_scalar_storage_callback") @@ -131,14 +132,35 @@ def test_written_back_callback_scalars_default_to_rank_zero_storage(): transfers = policy.arguments[0].callback.arguments assert [transfer.intent for transfer in transfers] == ["inout", "out", None] - assert [transfer.python_action for transfer in transfers] == [ - PythonBarrierAction.SCALAR_STORAGE, - PythonBarrierAction.SCALAR_STORAGE, - PythonBarrierAction.SCALAR_VALUE, + assert [transfer.python_action for transfer in transfers] == [PythonBarrierAction.SCALAR_STORAGE] * 3 + assert [transfer.adapter_action for transfer in transfers] == [ + CallbackTransferAction.COPY_IN_OUT, + CallbackTransferAction.COPY_OUT, + CallbackTransferAction.COPY_IN_OUT, ] assert policy.supported is True +def test_assume_intent_in_scalars_elects_the_input_only_default_for_an_undeclared_intent(): + """The flag chooses which default an undeclared ``intent`` receives. + + It narrows the conservative read/write default to input-only; it does not + give the dummy a declared direction, so the contract still carries none. + """ + module = _source_semantic_module( + "fcallback_all_f90.f90", + module_name="fcallback_all_f90", + assume_intent_in_scalars=True, + ) + function = next(item for item in module.functions if item.name == "apply_scalar_storage_callback") + transfers = completed_function_wrapper_policy(function).arguments[0].callback.arguments + + undeclared = transfers[2] + assert undeclared.intent is None + assert undeclared.python_action is PythonBarrierAction.SCALAR_VALUE + assert undeclared.adapter_action is CallbackTransferAction.COPY_IN + + @pytest.mark.parametrize( ("prototype", "blocker"), [ diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index cfdce8adb..8508fd412 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -119,7 +119,9 @@ def test_dummy_procedure_interfaces_become_complete_callable_contracts(): assert "callback: transform_iface" in emitted assert "@prototype\ndef value_iface(" in emitted assert "value: In(Int32)" in emitted - assert "ref: Addr(Float64)" in emitted + # A dummy with no declared intent keeps that absence in the contract while + # carrying storage the callee may write through. + assert "ref: Float64[()]" in emitted assert "@prototype\ndef string_iface(" in emitted assert "read_label: In(String[8])" in emitted assert native_contract_issues(parse_pyi_text(emitted, module_name=module.name)) == [] From 85c589338ddf15fa3e7e8a69c157cc3835661e00 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 01:10:28 +0100 Subject: [PATCH 03/24] Prove the undeclared callback intent through a contract round trip The callback key rules kept a sentence stating that every primitive scalar callback argument arrives as an independent NumPy scalar value, which contradicted the writable-storage rule documented directly below it. One rule now covers both projections. The undeclared-intent regression asserted the generated contract text and then rebuilt from the Fortran source, so nothing proved the bare Float64[()] spelling survived being read back. It now builds through that generated contract and runs the callback, covering source, contract, policy, codegen and runtime in one pass; the shared helper takes an optional fixture package so a round trip needs no checked-in contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- docs/user/guide/callbacks.md | 9 +++-- tests/fortran/_support/wrapper_build.py | 13 ++++++-- .../test_callback_scalar_storage.py | 33 ++++++++++--------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/docs/user/guide/callbacks.md b/docs/user/guide/callbacks.md index 6dcf8dd5a..24e97b5e6 100644 --- a/docs/user/guide/callbacks.md +++ b/docs/user/guide/callbacks.md @@ -289,11 +289,10 @@ derived-type callback dummy declared with the Fortran `value` attribute. - The callback is only valid **during** the wrapped native call. - Native code must not store the callback for later use. - Return the exact NumPy scalar type when PRIK expects a scalar callback result. -- Primitive scalar callback arguments arrive as independent NumPy scalar values, - whether the native dummy is `value` or reference. -- Primitive scalar `in` arguments arrive as independent values. Arguments the - callee may write — `out`, `inout`, or no declared `intent` — arrive as - rank-zero storage you assign through. +- Primitive scalar callback arguments projected as values arrive as independent + NumPy scalar values, whether the native dummy is `value` or reference. + Writable reference scalars — `out`, `inout`, or no declared `intent` — arrive + as rank-zero storage you assign through. - Arrays and derived-type arguments can expose live native state; copy data you need after the wrapped call returns. diff --git a/tests/fortran/_support/wrapper_build.py b/tests/fortran/_support/wrapper_build.py index 3fe6c0834..3cc11d43d 100644 --- a/tests/fortran/_support/wrapper_build.py +++ b/tests/fortran/_support/wrapper_build.py @@ -210,7 +210,8 @@ def _compile_native_object(source: Path, native_dir: Path) -> Path: return native_object -def _generate_checked_pyi_contract(source: Path, package_dir: Path, expected_package: Path) -> Path: +def _generate_checked_pyi_contract(source: Path, package_dir: Path, expected_package: Path | None) -> Path: + """Generate one contract package, comparing it to a fixture when given.""" _run_captured_command( [ sys.executable, @@ -225,7 +226,8 @@ def _generate_checked_pyi_contract(source: Path, package_dir: Path, expected_pac _compiler(), ], ) - assert_generated_pyi_package_matches_fixture(package_dir, expected_package) + if expected_package is not None: + assert_generated_pyi_package_matches_fixture(package_dir, expected_package) return package_dir / "__init__.pyi" @@ -262,7 +264,12 @@ def _build_inline_pyi_contract_module( return module, result -def _build_generated_pyi_and_import(source_template: Path, workdir: Path, expected_contract_package: Path): +def _build_generated_pyi_and_import( + source_template: Path, + workdir: Path, + expected_contract_package: Path | None = None, +): + """Generate a contract from source, then build and import through that contract.""" source_dir = workdir / "source" source_dir.mkdir(parents=True) source = source_dir / source_template.name diff --git a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py index d9cee3fc9..701f2363e 100644 --- a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py +++ b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py @@ -1,13 +1,12 @@ """Rank-zero callback storage: writable scalar dummies reach native memory.""" -import subprocess -import sys from pathlib import Path import numpy as np import pytest from tests.fortran._support.wrapper_build import ( + _build_generated_pyi_and_import, _build_inline_pyi_contract_module, _build_source_and_import, ) @@ -225,26 +224,28 @@ def tweak(value): assert observed == [7.0] -def test_undeclared_intent_stays_undeclared_in_the_generated_contract(tmp_path: Path): - """The conservative transfer must not invent a direction the source lacks. +def test_undeclared_intent_survives_the_generated_contract_round_trip(tmp_path: Path): + """The absent ``intent`` must survive source, contract, codegen and runtime. - The contract records the absent ``intent`` by carrying no direction - wrapper, and the generated interface body declares the dummy without one. + Building through PRIK's own generated contract proves the bare + ``Float64[()]`` spelling carries the conservative read/write transfer all + the way to the trampoline, rather than only appearing in the contract text. """ source = tmp_path / "fcallback_undeclared_intent_f90.f90" source.write_text(SOURCE_UNDECLARED, encoding="utf-8") - contracts = tmp_path / "contracts" - subprocess.run( - [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(contracts)], - check=True, - capture_output=True, - ) - contract = (contracts / "fcallback_undeclared_intent_f90.pyi").read_text(encoding="utf-8") + workdir = tmp_path / "round_trip" + module = _build_generated_pyi_and_import(source, workdir) + contract = (workdir / "contracts" / source.stem / f"{source.stem}.pyi").read_text(encoding="utf-8") assert "value: Float64[()]" in contract assert "In(" not in contract and "Out(" not in contract and "InOut(" not in contract - _undeclared_intent_module(tmp_path) - bridge = (tmp_path / "build" / "bind_c_fcallback_undeclared_intent_f90_wrapper.f90").read_text(encoding="utf-8") + bridge = next((workdir / "pyi_build").glob("bind_c_*_wrapper.f90")).read_text(encoding="utf-8") assert "real(c_double) :: value" in bridge - assert "intent(inout) :: value" not in bridge + assert not any(f"intent({direction}) :: value" in bridge for direction in ("in", "out", "inout")) + assert "value = value_callback_storage" in bridge + + def tweak(value): + value[...] = float(value) * 3.0 + + assert module.drive(tweak, np.float64(7.0)) == np.float64(21.0) From ec7002af7b70685663584c634033d7c2428b953c Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 01:58:40 +0100 Subject: [PATCH 04/24] Resolve imported callback interfaces in their declaring module An abstract interface imported from another module was converted in the scope of the module that imported it. An interface body is written where it is declared, so a derived type it names belongs to the declaring module; a consumer that imported only the interface attributed that type to itself and failed with no completed wrapper type definition for a type it never declares. Interface lookup now carries the declaring module, the prototype's dummies convert in that module's context, and a type local to the declaring module records it as the origin. Resolution also stopped at module-level imports. A `use` inside a single procedure, a standalone procedure's own imports, and an interface re-exported through another module now all resolve, following a chain of any length to the module that declares it. File, project and per-file CLI conversion share one resolver instead of each carrying its own lookup, and contract reconciliation follows a re-export so a prototype imported from a module that only republishes it still binds to its declaration. A contract now also imports a prototype it references but never declares, which a procedure-local `use` previously left as a free name. Callback docstrings state each array argument's rank and extents, taken from the completed transfer plan, and every generated docstring spells a runtime extent the way the contract spells it rather than exposing the internal marker. Regression coverage: imported interfaces owning derived types, the three resolution routes, multi-file `generate --pyi` through parse and build including a renamed import, rank-two assumed-shape callbacks, writable scalar storage on the bridge-free direct bind(C) route, and the `--assume-intent-in-scalars` override end to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 20 ++ prik/codegen/docstrings.py | 26 +- prik/printers/pyi.py | 40 +++ prik/semantics/fortran2ir.py | 245 +++++++++++++----- prik/semantics/pyi2ir.py | 44 +++- .../codegen/test_callback_planning.py | 53 ++++ .../end_to_end/test_array_callbacks.py | 60 ++++- .../test_callback_scalar_storage.py | 39 ++- .../test_direct_bind_c_callback_storage.py | 90 +++++++ .../test_multi_file_contract_generation.py | 142 ++++++++++ .../callbacks/policy/test_callback_policy.py | 48 ++++ .../test_fortran_callback_semantics.py | 102 +++++++- 12 files changed, 835 insertions(+), 74 deletions(-) create mode 100644 tests/fortran/callbacks/end_to_end/test_direct_bind_c_callback_storage.py create mode 100644 tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e425af5..4fcbedd54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ release tags add a leading `v` to the package version. ## Unreleased +- An abstract interface imported from another module now converts in the scope + of the module that declares it. A derived type the interface names belongs to + that module, so wrapping a consumer that imports only the interface — and not + the types it mentions — no longer fails against a type identity attributed to + the consuming module. + +- Callback interface resolution now covers a `use` inside a single procedure, a + standalone procedure's own imports, and an interface re-exported through any + number of modules. File, project, and `generate --pyi` conversion share one + resolver rather than each carrying its own lookup, and a contract that + re-exports a prototype resolves back to the module that declares it. + +- A contract now imports a prototype it references but never declares, so an + interface named by a procedure-local `use` is bound in the generated `.pyi` + instead of appearing as a free name. + +- Callback docstrings now state each array argument's rank and extents, and + every generated docstring spells a runtime extent the way the `.pyi` contract + spells it (`::`) rather than exposing the internal marker. + - A primitive scalar callback dummy the callee may write now reaches Python as rank-zero storage (`Out(Float64[()])`) instead of an independent value, so the value the callback computes reaches the native caller. This covers diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index bc0c826f7..c3c2eeffb 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -69,6 +69,8 @@ _LOGICAL_ARRAY_NOTE = "Fortran logical elements; compare with .astype(bool) rather than to 1." _UNKNOWN_EXTENTS = frozenset({"", ":", "::", "*", ".."}) +# A runtime extent is documented the way the `.pyi` contract spells it. +_PUBLIC_RUNTIME_EXTENTS = {"::Strided": "::"} class WrapperDocstringBuilder: @@ -1003,14 +1005,29 @@ def _callback_signature_lines(self, argument: ArgumentTransferPlan) -> tuple[str @staticmethod def _callback_parameter_text(transfer: CallbackTransferPlan) -> str: - """Render one prototype dummy with the access its projection allows.""" - text = f"{transfer.name} : {WrapperDocstringBuilder._callback_transfer_type(transfer)}" + """Render one prototype dummy with the shape and access it presents.""" + parts = [f"{transfer.name} : {WrapperDocstringBuilder._callback_transfer_type(transfer)}"] + parts.extend(WrapperDocstringBuilder._callback_array_facts(transfer.array)) if transfer.intent is not None: - text += f", intent({transfer.intent})" + parts.append(f"intent({transfer.intent})") + text = ", ".join(parts) if transfer.python_action is PythonBarrierAction.SCALAR_STORAGE: text += f"; assign through it ({transfer.name}[...] = value)" return text + @staticmethod + def _callback_array_facts(array: ArrayHandoffPlan | None) -> tuple[str, ...]: + """Describe one callback array's rank and extents from its completed plan. + + The callable's ABI depends on both, and extents are spelled the way the + `.pyi` contract spells them so the two descriptions agree. + """ + if array is None or not array.rank: + return () + display = array.display_shape or array.shape + extents = ", ".join(_PUBLIC_RUNTIME_EXTENTS.get(str(extent), str(extent)) for extent in display) + return (f"rank {array.rank}",) + ((f"shape ({extents})",) if extents else ()) + @staticmethod def _callback_result_type(result: CallbackResultPlan) -> str: """Render what the callable must return, or ``None`` for a subroutine.""" @@ -1035,7 +1052,8 @@ def _array_lines(array: ArrayHandoffPlan | None) -> tuple[str, ...]: lines = [WrapperDocstringBuilder._array_rank_line(array)] display_shape = array.display_shape or array.shape if display_shape and all(str(extent) not in _UNKNOWN_EXTENTS for extent in display_shape): - lines.append(f" Shape: ({', '.join(map(str, display_shape))})") + extents = (_PUBLIC_RUNTIME_EXTENTS.get(str(extent), str(extent)) for extent in display_shape) + lines.append(f" Shape: ({', '.join(extents)})") layout = WrapperDocstringBuilder._array_layout_label(array) if layout is not None: lines.append(f" Layout: {layout}") diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 678ff0447..645d982e6 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -1467,9 +1467,49 @@ def _effective_imports(cls, module: SemanticModule) -> list[str | SemanticImport satisfied_namespaces = cls._satisfied_procedure_namespace_import_names(imports, procedure_namespaces) imports.extend(cls._synthetic_flat_external_type_imports(module, imports, procedure_namespaces)) imports.extend(cls._missing_expression_callable_imports(module, imports)) + imports.extend(cls._missing_prototype_imports(module, imports)) imports.extend(cls._missing_procedure_namespace_imports(procedure_namespaces, satisfied_namespaces)) return imports + @classmethod + def _missing_prototype_imports( + cls, + module: SemanticModule, + imports: list[str | SemanticImport], + ) -> list[SemanticImport]: + """Return imports for prototypes this module references but never declares. + + A ``use`` inside one procedure names an interface without appearing in + the module's own imports, so the annotation would reference a name the + contract never binds. The prototype reference records where it came + from, which is enough to bind it explicitly. + """ + bound = { + (item.target or item.source).casefold() + for imported in imports + if isinstance(imported, SemanticImport) + for item in imported.items + } + bound.update(prototype.name.casefold() for prototype in module.prototypes) + required: dict[str, list[SemanticImportItem]] = {} + for semantic_type in _module_semantic_types(module): + reference = semantic_type.metadata.get(PROTOTYPE_REF_METADATA) + if not isinstance(reference, dict): + continue + local_name = str(reference.get("local_name") or reference.get("name") or "") + origin = str(reference.get("origin_module") or "") + if not local_name or not origin or local_name.casefold() in bound: + continue + native_name = str(reference.get("name") or local_name) + required.setdefault(origin, []).append( + SemanticImportItem( + source=native_name, + target=local_name if local_name != native_name else None, + ) + ) + bound.add(local_name.casefold()) + return [SemanticImport(module=name, items=items) for name, items in required.items()] + @classmethod def _missing_expression_callable_imports( cls, diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index ccdf2ab0e..8cdcaafbc 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -173,6 +173,20 @@ # Internal conversion context +@dataclass(frozen=True) +class _CallbackInterface: + """Pair one resolvable callback interface with the module that declares it. + + The declaring module is what makes an imported interface convertible: its + dummies are written in that module's lexical scope, so a derived type the + interface names belongs to the declaring module even when the consuming + module never imports that type. + """ + + signature: FortranProcedureSignature + module: FortranModule | None = None + + @dataclass(frozen=True) class _DerivedTypeContext: """Keep lexical derived-type lookup facts while one parser node is converted. @@ -358,20 +372,15 @@ def _visit_FortranFile( converter = self._with_additional_wrapped_types(self._wrapped_types_from_file(parsed_file)) converter = converter._with_additional_known_procedures(self._known_procedures_from_file(parsed_file)) converter = converter._with_additional_abstract_types(self._abstract_types_from_file(parsed_file)) - known_modules = {item.name.casefold(): item for item in (*sibling_modules, *parsed_file.modules)} - modules = [ - converter.visit( - module, - callback_interfaces=self._imported_callback_interface_lookup(known_modules, module), - ) - for module in parsed_file.modules - ] + index = self._callback_module_index(sibling_modules, parsed_file.modules) + modules = [converter.visit(module, module_index=index) for module in parsed_file.modules] if parsed_file.procedures: modules.append( converter.procedures_to_semantic_module( parsed_file.procedures, name=standalone_module_name or self._standalone_module_name(parsed_file), - callback_interfaces=self._callback_interface_lookup(parsed_file), + callback_interfaces=self._declared_callback_interfaces(parsed_file), + module_index=index, ) ) return modules @@ -386,22 +395,21 @@ def _visit_FortranProject(self, project: FortranProject) -> list[SemanticModule] converter = self._with_additional_wrapped_types(self._wrapped_types_from_project(project)) converter = converter._with_additional_known_procedures(self._known_procedures_from_project(project)) converter = converter._with_additional_abstract_types(self._abstract_types_from_project(project)) + index = self._callback_module_index( + project.modules.values(), + (module for parsed_file in project.files for module in parsed_file.modules), + ) semantic_modules = [] for parsed_file in project.files: file_converter = converter._with_additional_wrapped_types(converter._wrapped_types_from_file(parsed_file)) - semantic_modules.extend( - file_converter.visit( - module, - callback_interfaces=self._project_callback_interface_lookup(project, module), - ) - for module in parsed_file.modules - ) + semantic_modules.extend(file_converter.visit(module, module_index=index) for module in parsed_file.modules) if parsed_file.procedures: semantic_modules.append( file_converter.procedures_to_semantic_module( parsed_file.procedures, name=self._standalone_module_name(parsed_file), - callback_interfaces=self._callback_interface_lookup(parsed_file), + callback_interfaces=self._declared_callback_interfaces(parsed_file), + module_index=index, ) ) return semantic_modules @@ -523,7 +531,7 @@ def _visit_FortranArgument( arg: FortranArgument | FortranVariable, *, derived_type_context: _DerivedTypeContext | None = None, - callback_interfaces: dict[str, FortranProcedureSignature] | None = None, + callback_interfaces: dict[str, _CallbackInterface] | None = None, as_data_member: bool = False, as_type: bool = False, binding_cls: type[SemanticVariable] = SemanticVariable, @@ -583,7 +591,7 @@ def _argument_semantic_type( self, arg: FortranArgument | FortranVariable, *, - callback_interfaces: dict[str, FortranProcedureSignature] | None, + callback_interfaces: dict[str, _CallbackInterface] | None, derived_type_context: _DerivedTypeContext | None, declaration_arrays: dict[str, ArrayExpressionSource] | None, ) -> SemanticType: @@ -681,60 +689,106 @@ def _convert_data_member( return binding @staticmethod - def _callback_interface_lookup( - module: FortranModule | FortranFile, - ) -> dict[str, FortranProcedureSignature]: - """Index explicit and abstract interface procedures usable by dummy procedures.""" - lookup: dict[str, FortranProcedureSignature] = {} - for interface in module.interfaces: + def _declared_callback_interfaces( + container: FortranModule | FortranFile, + ) -> dict[str, _CallbackInterface]: + """Index interfaces declared directly in one module or file.""" + owner = container if isinstance(container, FortranModule) else None + lookup: dict[str, _CallbackInterface] = {} + for interface in container.interfaces: for signature in interface.procedures: - lookup.setdefault(signature.name.casefold(), signature) + lookup.setdefault(signature.name.casefold(), _CallbackInterface(signature, owner)) if interface.name and len(interface.procedures) == 1: - lookup.setdefault(interface.name.casefold(), interface.procedures[0]) + lookup.setdefault(interface.name.casefold(), _CallbackInterface(interface.procedures[0], owner)) return lookup + @staticmethod + def _callback_module_index(*containers: Iterable[FortranModule]) -> dict[str, FortranModule]: + """Index every known module by casefolded name for interface resolution.""" + return {module.name.casefold(): module for group in containers for module in group} + @classmethod - def _project_callback_interface_lookup( + def _module_callback_interfaces( cls, - project: FortranProject, + modules: dict[str, FortranModule], module: FortranModule, - ) -> dict[str, FortranProcedureSignature]: - """Resolve abstract interfaces imported from another parsed project module.""" - modules = {name.casefold(): item for name, item in project.modules.items()} - modules.update({item.name.casefold(): item for parsed_file in project.files for item in parsed_file.modules}) - return cls._imported_callback_interface_lookup(modules, module) + *, + seen: frozenset[str] = frozenset(), + ) -> dict[str, _CallbackInterface]: + """Index every interface name visible in one module. + + Declarations of the module itself take precedence over imported names, + and an import is followed through re-exporting modules so a chain of + ``use`` hops resolves to the module that actually declares it. A module + outside the index leaves its names unresolved, which later stages report + against the ``use`` that named them. + """ + key = module.name.casefold() + if key in seen: + return {} + visible = cls._declared_callback_interfaces(module) + cls._merge_imported_callback_interfaces( + visible, + modules, + module.uses, + seen=seen | {key}, + override=False, + ) + return visible @classmethod - def _imported_callback_interface_lookup( + def _scope_callback_interfaces( cls, modules: dict[str, FortranModule], - module: FortranModule, - ) -> dict[str, FortranProcedureSignature]: - """Resolve abstract interfaces imported from another known module. + uses: dict[str, list[FortranUseMapping]], + *, + base: dict[str, _CallbackInterface], + ) -> dict[str, _CallbackInterface]: + """Extend a visible interface set with one inner scope's own imports. - The index is keyed by casefolded module name; an interface declared in - a module outside it stays unresolved, which later stages report against - the ``use`` that named it. + A procedure-local or standalone-procedure ``use`` names the interface in + that scope, so it takes precedence over anything the enclosing scope + made visible under the same name. """ - imported: dict[str, FortranProcedureSignature] = {} - for module_name, mappings in module.uses.items(): + visible = dict(base) + cls._merge_imported_callback_interfaces(visible, modules, uses, seen=frozenset(), override=True) + return visible + + @classmethod + def _merge_imported_callback_interfaces( + cls, + visible: dict[str, _CallbackInterface], + modules: dict[str, FortranModule], + uses: dict[str, list[FortranUseMapping]], + *, + seen: frozenset[str], + override: bool, + ) -> None: + """Merge every interface one ``use`` list makes visible into ``visible``.""" + for module_name, mappings in uses.items(): source_module = modules.get(module_name.casefold()) if source_module is None: continue - source_lookup = cls._callback_interface_lookup(source_module) - if not mappings: - imported.update(source_lookup) - continue - for mapping in mappings: - signature = source_lookup.get(mapping.source.casefold()) - if signature is not None: - imported[mapping.local_name.casefold()] = signature - return imported + source_lookup = cls._module_callback_interfaces(modules, source_module, seen=seen) + imported = ( + source_lookup + if not mappings + else { + mapping.local_name.casefold(): resolved + for mapping in mappings + if (resolved := source_lookup.get(mapping.source.casefold())) is not None + } + ) + for name, resolved in imported.items(): + if override: + visible[name] = resolved + else: + visible.setdefault(name, resolved) def _callback_semantic_type( self, arg: FortranArgument | FortranVariable, - callback_interfaces: dict[str, FortranProcedureSignature], + callback_interfaces: dict[str, _CallbackInterface], *, derived_type_context: _DerivedTypeContext | None, ) -> SemanticType: @@ -748,7 +802,8 @@ def _callback_semantic_type( if getattr(arg, "pointer", False): return self._convert_variable_type(arg, derived_type_context=derived_type_context) interface_name = str(arg.kind or arg.name) - signature = callback_interfaces.get(interface_name.casefold()) + resolved = callback_interfaces.get(interface_name.casefold()) + signature = resolved.signature if resolved is not None else None if signature is None: semantic_type = self._convert_variable_type(arg, derived_type_context=derived_type_context) if getattr(arg, "kind", None): @@ -758,12 +813,26 @@ def _callback_semantic_type( semantic_type.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] = interface_name return semantic_type - context = self._procedure_derived_type_context(signature, derived_type_context) + # An interface body is written in the scope of the module that declares + # it, so its dummies resolve there rather than in the module that + # imported the interface -- which need not import the types it names. + declaring_context = ( + self._module_derived_type_context(resolved.module) + if resolved is not None and resolved.module is not None + else derived_type_context + ) + context = self._procedure_derived_type_context(signature, declaring_context) projected_arguments = list(signature.arguments) callback_arguments = [self.visit(item, derived_type_context=context) for item in projected_arguments] for source_argument, callback_argument in zip(projected_arguments, callback_arguments, strict=True): self._normalize_callback_reference_storage(callback_argument, source_argument) self._record_prototype_argument_intent(callback_argument, source_argument) + self._record_imported_prototype_type_origin( + callback_argument, + source_argument, + resolved, + derived_type_context, + ) callback_return = ( self.visit(signature.result, derived_type_context=context, as_type=True) if signature.result @@ -863,6 +932,41 @@ def _is_written_back_callback_scalar( return not self.assume_intent_in_scalars return str(intent).casefold() in {"out", "inout"} + def _record_imported_prototype_type_origin( + self, + callback_argument: SemanticArgument, + source_argument: FortranArgument | FortranVariable, + resolved: _CallbackInterface | None, + consuming_context: _DerivedTypeContext | None, + ) -> None: + """Name the declaring module for a derived type an imported interface owns. + + A type declared beside the interface is local to that module, so nothing + in the module that imported the interface identifies it. Recording the + origin keeps the identity with the module that declares the type rather + than the one that happened to import the interface. + """ + if resolved is None or resolved.module is None: + return + if str(getattr(source_argument, "base_type", "")).casefold() != "derived": + return + declaring = resolved.module.name + consuming = str(consuming_context.module or "") if consuming_context is not None else "" + if declaring.casefold() == consuming.casefold(): + return + semantic_type = callback_argument.semantic_type + if EXTERNAL_TYPE_REF_METADATA in semantic_type.metadata: + return + name = str(getattr(source_argument, "kind", "") or semantic_type.name) + wrapped = (declaring.casefold(), name.casefold()) in self.wrapped_derived_types + semantic_type.metadata[EXTERNAL_TYPE_REF_METADATA] = { + "name": name, + "local_name": name, + "origin_module": declaring, + "wrapped": wrapped, + "representation": "wrapped" if wrapped else "opaque", + } + @staticmethod def _record_prototype_argument_intent( argument: SemanticArgument, @@ -986,7 +1090,7 @@ def _visit_FortranProcedureSignature( visibility: str = "public", *, derived_type_context: _DerivedTypeContext | None = None, - callback_interfaces: dict[str, FortranProcedureSignature] | None = None, + callback_interfaces: dict[str, _CallbackInterface] | None = None, ) -> SemanticFunction: """Convert a parsed procedure signature into its callable semantic contract. @@ -1166,7 +1270,7 @@ def _visit_FortranModule( self, module: FortranModule, *, - callback_interfaces: dict[str, FortranProcedureSignature] | None = None, + module_index: dict[str, FortranModule] | None = None, ) -> SemanticModule: """Assemble the semantic contents of one parsed Fortran module. @@ -1177,10 +1281,8 @@ def _visit_FortranModule( """ context = self._module_derived_type_context(module) self._record_abstract_type_names(module) - callback_interfaces = { - **(callback_interfaces or {}), - **self._callback_interface_lookup(module), - } + index = module_index if module_index is not None else self._callback_module_index([module]) + callback_interfaces = self._module_callback_interfaces(index, module) source_procedures = [ *module.procedures, *self._module_explicit_interface_procedures(module), @@ -1190,7 +1292,9 @@ def _visit_FortranModule( proc, visibility=self._symbol_visibility(module, proc.name), derived_type_context=context, - callback_interfaces=callback_interfaces, + # A procedure-local ``use`` names an interface only inside that + # procedure, so each one resolves against its own imports. + callback_interfaces=self._scope_callback_interfaces(index, proc.uses, base=callback_interfaces), ) for proc in source_procedures ] @@ -1339,15 +1443,24 @@ def procedures_to_semantic_module( procedures: list[FortranProcedureSignature], *, name: str, - callback_interfaces: dict[str, FortranProcedureSignature] | None = None, + callback_interfaces: dict[str, _CallbackInterface] | None = None, + module_index: dict[str, FortranModule] | None = None, ) -> SemanticModule: """Package standalone procedures as the synthetic semantic module ``name``. This is used by file and project conversion after parser module handling. - Procedure order and optional callback lookup are passed unchanged to the - existing procedure visitor. + Procedure order is preserved, and each procedure resolves interfaces from + its own ``use`` list on top of the supplied file-level lookup. """ - semantic_functions = [self.visit(proc, callback_interfaces=callback_interfaces) for proc in procedures] + index = module_index or {} + base = callback_interfaces or {} + semantic_functions = [ + self.visit( + proc, + callback_interfaces=self._scope_callback_interfaces(index, proc.uses, base=base), + ) + for proc in procedures + ] function_lookup = {function.name.casefold(): function for function in semantic_functions} for procedure, function in zip(procedures, semantic_functions, strict=True): self._record_function_declaration_callables( diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index adde496e8..4fa6ce1ca 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -3883,6 +3883,48 @@ def _bind_prototype_reference( ) +def _external_module_candidates(module_name: str) -> tuple[str, ...]: + """Return the spellings one import may use to name the same contract module.""" + stripped = module_name.lstrip(".") + return tuple( + dict.fromkeys(candidate for candidate in (module_name, stripped, stripped.rsplit(".", 1)[-1]) if candidate) + ) + + +def _prototypes_with_reexports(modules: list[SemanticModule]) -> dict[tuple[str, str], SemanticPrototype]: + """Index every prototype name a contract module binds, declared or re-exported. + + A module that imports a prototype and publishes it binds that name without + declaring it, so a consumer importing it from there must still resolve to + the declaring module. Repeating to a fixed point follows a chain of any + length. + """ + resolved = {(module.name, prototype.name): prototype for module in modules for prototype in module.prototypes} + changed = True + while changed: + changed = False + for module in modules: + for imported in module.imports: + if not isinstance(imported, SemanticImport): + continue + for item in imported.items: + local_name = item.target or item.source + if (module.name, local_name) in resolved: + continue + prototype = next( + ( + found + for candidate in _external_module_candidates(imported.module) + if (found := resolved.get((candidate, item.source))) is not None + ), + None, + ) + if prototype is not None: + resolved[(module.name, local_name)] = prototype + changed = True + return resolved + + def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[SemanticModule]: """Resolve imported class and prototype references across converted modules. @@ -3893,7 +3935,7 @@ def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[Semantic pipeline chaining; absent external definitions remain opaque references. """ definitions = {(module.name, declaration.name): declaration for module in modules for declaration in module.classes} - prototypes = {(module.name, prototype.name): prototype for module in modules for prototype in module.prototypes} + prototypes = _prototypes_with_reexports(modules) functions = {(module.name, function.name): function for module in modules for function in module.functions} for module in modules: for semantic_type in _module_semantic_types(module): diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 874af1888..7dc3f8351 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -308,3 +308,56 @@ def apply_directions(callback: directions_callback) -> None: ... for parameter in ("update_value", "write_value"): writable = f"PyArray_New(&PyArray_Type, 0, NULL, NPY_FLOAT64, NULL, {parameter}_data, 0, " assert f"{writable}NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" in c_source + + +MATRIX_CONTRACT = """ +from prik.contracts import Float64, In, Out, prototype + +@prototype +def matrix_callback( + input: In(Float64[::, ::]), + output: Out(Float64[::, ::]) +) -> None: ... + +def apply_matrix(callback: matrix_callback) -> None: ... +""" + + +def _matrix_plan(): + module = pyi_text_to_semantic_module(MATRIX_CONTRACT, module_name="callback_matrix") + complete_semantic_policies(module) + return WrapperPlanner().build(module) + + +def test_multidimensional_runtime_extents_measure_every_axis_from_the_dummy(): + """Each axis of an assumed-shape callback array is lowered independently. + + A rank-one fix can silently ignore later axes, so the copy that backs + ``c_loc`` must be measured on every axis of the dummy it sits beside. + """ + plan = _matrix_plan() + callback = _callback_argument(plan, "apply_matrix").callback + assert [transfer.array.rank for transfer in callback.arguments] == [2, 2] + + _, bridge = _sources(plan) + assert "::Strided" not in bridge + assert "real(c_double), intent(in), dimension(:, :) :: input" in bridge + assert "real(c_double), target, dimension(size(input, 1), size(input, 2)) :: input_callback_storage" in bridge + assert "real(c_double), intent(out), dimension(:, :) :: output" in bridge + assert "real(c_double), target, dimension(size(output, 1), size(output, 2)) :: output_callback_storage" in bridge + + +def test_callback_docstrings_carry_array_rank_and_public_extents(): + """A callable's ABI depends on rank and shape, so both are documented. + + Extents use the spelling the `.pyi` contract uses, so the two descriptions + of the same array agree and no internal marker reaches the reader. + """ + plan = _matrix_plan() + c_source, _bridge = _sources(plan) + documentation = c_source.encode().decode("unicode_escape") + + assert "Called as: callback(input, output) -> None" in documentation + assert "input : ndarray[float64], rank 2, shape (::, ::), intent(in)" in documentation + assert "output : ndarray[float64], rank 2, shape (::, ::), intent(out)" in documentation + assert "::Strided" not in documentation diff --git a/tests/fortran/callbacks/end_to_end/test_array_callbacks.py b/tests/fortran/callbacks/end_to_end/test_array_callbacks.py index 82a12c0fa..fd1147177 100644 --- a/tests/fortran/callbacks/end_to_end/test_array_callbacks.py +++ b/tests/fortran/callbacks/end_to_end/test_array_callbacks.py @@ -5,7 +5,10 @@ import numpy as np import pytest -from tests.fortran._support.wrapper_build import _build_source_or_generated_pyi_and_import +from tests.fortran._support.wrapper_build import ( + _build_source_and_import, + _build_source_or_generated_pyi_and_import, +) FIXTURES = Path(__file__).parent / "fixtures" CALLBACK_ARRAY_F90_SOURCE = FIXTURES / "native" / "fcallback_array_f90.f90" @@ -69,3 +72,58 @@ def double(data, output): assert module.apply_assumed_shape(double, values, doubled) is None np.testing.assert_array_equal(seen[0], values) np.testing.assert_array_equal(doubled, values * 2.0) + + +MATRIX_SOURCE = """ +module fcallback_matrix_f90 + implicit none + + abstract interface + subroutine matrix_callback(input, output) + real(8), intent(in) :: input(:,:) + real(8), intent(out) :: output(:,:) + end subroutine matrix_callback + end interface + +contains + subroutine apply_matrix(callback, input, output) + procedure(matrix_callback) :: callback + real(8), intent(in) :: input(:,:) + real(8), intent(out) :: output(:,:) + + call callback(input, output) + end subroutine apply_matrix +end module fcallback_matrix_f90 +""" + + +def test_rank_two_assumed_shape_callback_arrays_cross_both_directions(tmp_path: Path): + """Every axis of a multidimensional assumed-shape dummy must survive. + + A rank-one lowering can look correct while dropping later axes, so this + checks the extents the callable observes and the data written back. + """ + source = tmp_path / "fcallback_matrix_f90.f90" + source.write_text(MATRIX_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_fcallback_matrix_f90_wrapper.f90", + "fcallback_matrix_f90_wrapper.c", + "fcallback_matrix_f90_wrapper.h", + }, + ) + incoming = np.asfortranarray(np.arange(6, dtype=np.float64).reshape(2, 3)) + written = np.asfortranarray(np.zeros((2, 3), dtype=np.float64)) + observed = {} + + def double(input_values, output_values): + observed["shape"] = input_values.shape + observed["values"] = np.array(input_values) + output_values[...] = input_values * 2.0 + + assert module.apply_matrix(double, incoming, written) is None + assert observed["shape"] == (2, 3) + np.testing.assert_array_equal(observed["values"], incoming) + np.testing.assert_array_equal(written, incoming * 2.0) diff --git a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py index 701f2363e..42719594f 100644 --- a/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py +++ b/tests/fortran/callbacks/end_to_end/test_callback_scalar_storage.py @@ -164,7 +164,7 @@ def test_callback_docstring_states_the_callable_signature_and_write_through(tmp_ documentation = module.evaluate.__doc__ assert "Called as: calfun(x, f) -> None" in documentation - assert "x : ndarray[float64], intent(in)" in documentation + assert "x : ndarray[float64], rank 1, shape (::), intent(in)" in documentation assert "f : ndarray[float64], intent(out); assign through it (f[...] = value)" in documentation assert "An exception or an invalid return value terminates the process." in documentation @@ -249,3 +249,40 @@ def tweak(value): value[...] = float(value) * 3.0 assert module.drive(tweak, np.float64(7.0)) == np.float64(21.0) + + +def test_assume_intent_in_scalars_makes_an_undeclared_callback_scalar_input_only(tmp_path: Path): + """The flag narrows the default without declaring a direction. + + The contract still carries no direction wrapper, because the source still + declares none; only the projection and the copy direction change. + """ + source = tmp_path / "fcallback_undeclared_intent_f90.f90" + source.write_text(SOURCE_UNDECLARED, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_fcallback_undeclared_intent_f90_wrapper.f90", + "fcallback_undeclared_intent_f90_wrapper.c", + "fcallback_undeclared_intent_f90_wrapper.h", + }, + assume_intent_in_scalars=True, + ) + contract = (tmp_path / "build" / "contracts" / "fcallback_undeclared_intent_f90.pyi").read_text(encoding="utf-8") + assert "value: Addr(Float64)" in contract + assert "In(" not in contract and "Out(" not in contract and "InOut(" not in contract + + bridge = (tmp_path / "build" / "bind_c_fcallback_undeclared_intent_f90_wrapper.f90").read_text(encoding="utf-8") + assert "real(c_double) :: value" in bridge + assert not any(f"intent({direction}) :: value" in bridge for direction in ("in", "out", "inout")) + # Input-only: nothing is copied back out of the call-local storage. + assert "value = value_callback_storage" not in bridge + + observed = [] + + def tweak(value): + observed.append(float(value)) + + assert module.drive(tweak, np.float64(7.0)) == np.float64(7.0) + assert observed == [7.0] diff --git a/tests/fortran/callbacks/end_to_end/test_direct_bind_c_callback_storage.py b/tests/fortran/callbacks/end_to_end/test_direct_bind_c_callback_storage.py new file mode 100644 index 000000000..7b706b257 --- /dev/null +++ b/tests/fortran/callbacks/end_to_end/test_direct_bind_c_callback_storage.py @@ -0,0 +1,90 @@ +"""Writable scalar callback storage on the bridge-free direct `bind(C)` route.""" + +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_source_and_import + +pytestmark = pytest.mark.fortran_end_to_end + +SOURCE = """ +module fcallback_direct_storage_f90 + use iso_c_binding + implicit none + + abstract interface + subroutine update_callback(value) bind(C) + import :: c_double + real(c_double), intent(inout) :: value + end subroutine update_callback + + subroutine emit_callback(value) bind(C) + import :: c_double + real(c_double), intent(out) :: value + end subroutine emit_callback + end interface + +contains + real(c_double) function drive_update(callback, seed) bind(C) result(output) + procedure(update_callback) :: callback + real(c_double), value, intent(in) :: seed + + output = seed + call callback(output) + end function drive_update + + real(c_double) function drive_emit(callback) bind(C) result(output) + procedure(emit_callback) :: callback + + call callback(output) + end function drive_emit +end module fcallback_direct_storage_f90 +""" + + +def _direct_module(tmp_path: Path): + source = tmp_path / "fcallback_direct_storage_f90.f90" + source.write_text(SOURCE, encoding="utf-8") + # A bind(C) entry point needs no generated Fortran adapter, so the expected + # source set is exactly the binding pair. + return _build_source_and_import( + source, + tmp_path / "build", + { + "fcallback_direct_storage_f90_wrapper.c", + "fcallback_direct_storage_f90_wrapper.h", + }, + ) + + +def test_direct_bind_c_callbacks_receive_writable_rank_zero_storage(tmp_path: Path): + """The projection must work where no Fortran bridge exists at all. + + A direct entry point calls the trampoline as a plain C function pointer, so + writable storage has to be the binding's doing rather than an adapter's. + """ + module = _direct_module(tmp_path) + observed = {} + + def update(value): + observed["writeable"] = value.flags.writeable + observed["incoming"] = float(value) + value[...] *= 2 + + def emit(value): + observed["emit_writeable"] = value.flags.writeable + value[...] = 42.0 + + assert module.drive_update(update, np.float64(5.0)) == np.float64(10.0) + assert module.drive_emit(emit) == np.float64(42.0) + assert observed == {"writeable": True, "incoming": 5.0, "emit_writeable": True} + + +def test_direct_bind_c_callback_storage_adds_no_fortran_bridge(tmp_path: Path): + """Scalar callback storage must not drag a bridge onto the direct route.""" + _direct_module(tmp_path) + generated = {path.name for path in (tmp_path / "build").glob("*_wrapper.f90")} + + assert generated == set() diff --git a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py new file mode 100644 index 000000000..14845ce53 --- /dev/null +++ b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py @@ -0,0 +1,142 @@ +"""Multi-file `generate --pyi`: imported interfaces reach a buildable contract.""" + +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _compiler, _import_from_build_dir +from prik import build_pyi_extension +from prik.pipeline.pyi import pyi_text_to_semantic_module +from prik.semantics.native_contract import native_contract_issues + +pytestmark = pytest.mark.fortran_end_to_end + +PINTRF_SOURCE = """ +module pintrf_mod + implicit none + private + public :: OBJ + + abstract interface + subroutine OBJ(x, f) + implicit none + real(8), intent(in) :: x + real(8), intent(out) :: f + end subroutine OBJ + end interface +end module pintrf_mod +""" + +SOLVER_SOURCE = """ +module solver_mod + use, non_intrinsic :: pintrf_mod, only : OBJ + implicit none +contains + subroutine minimize(calfun, x, f) + procedure(OBJ) :: calfun + real(8), intent(in) :: x + real(8), intent(out) :: f + + call calfun(x, f) + end subroutine minimize +end module solver_mod +""" + +RENAMED_SOURCE = """ +module renamed_mod + use, non_intrinsic :: pintrf_mod, only : LOCAL_OBJ => OBJ + implicit none +contains + subroutine minimize_renamed(calfun, x, f) + procedure(LOCAL_OBJ) :: calfun + real(8), intent(in) :: x + real(8), intent(out) :: f + + call calfun(x, f) + end subroutine minimize_renamed +end module renamed_mod +""" + + +def _generate_contracts(tmp_path: Path) -> tuple[Path, list[Path]]: + sources = [] + for name, text in ( + ("pintrf.f90", PINTRF_SOURCE), + ("solver.f90", SOLVER_SOURCE), + ("renamed.f90", RENAMED_SOURCE), + ): + path = tmp_path / name + path.write_text(text, encoding="utf-8") + sources.append(path) + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + *[str(path) for path in sources], + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + return contracts, sources + + +def test_multi_file_generation_places_the_prototype_with_its_declaring_module(tmp_path: Path): + """Each module's contract records what that module declares or imports. + + The per-file CLI conversion path is where an imported interface previously + degraded to an opaque placeholder, so this exercises that workflow rather + than whole-project conversion. + """ + contracts, _sources = _generate_contracts(tmp_path) + + declaring = (contracts / "pintrf_mod.pyi").read_text(encoding="utf-8") + assert "@prototype\ndef OBJ(" in declaring + + consuming = (contracts / "solver_mod.pyi").read_text(encoding="utf-8") + assert "from pintrf_mod import OBJ" in consuming + assert "calfun: OBJ" in consuming + + renamed = (contracts / "renamed_mod.pyi").read_text(encoding="utf-8") + assert "from pintrf_mod import OBJ as LOCAL_OBJ" in renamed + assert "calfun: LOCAL_OBJ" in renamed + + +def test_generated_multi_file_contracts_parse_without_native_contract_issues(tmp_path: Path): + """PRIK must be able to read back every contract it just wrote.""" + contracts, _sources = _generate_contracts(tmp_path) + + for contract in sorted(contracts.glob("*.pyi")): + if contract.name == "__init__.pyi": + continue + module = pyi_text_to_semantic_module(contract.read_text(encoding="utf-8"), module_name=contract.stem) + assert native_contract_issues(module) == [], contract.name + + +def test_building_from_generated_multi_file_contracts_runs_the_callback(tmp_path: Path): + """The whole route must survive: source, contract, parse, build, call.""" + contracts, sources = _generate_contracts(tmp_path) + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(path) for path in sources], + output_dir=tmp_path / "build", + output_name="multi_file_callbacks", + ) + module = _import_from_build_dir(result.module_name, result.output_dir) + + def objective(x, f): + f[...] = float(x) ** 2 + + assert module.solver_mod.minimize(objective, np.float64(3.0)) == np.float64(9.0) + assert module.renamed_mod.minimize_renamed(objective, np.float64(4.0)) == np.float64(16.0) diff --git a/tests/fortran/callbacks/policy/test_callback_policy.py b/tests/fortran/callbacks/policy/test_callback_policy.py index 6a7ed76b0..750d21852 100644 --- a/tests/fortran/callbacks/policy/test_callback_policy.py +++ b/tests/fortran/callbacks/policy/test_callback_policy.py @@ -212,3 +212,51 @@ def apply(callback: callback_shape) -> None: ... policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] assert policy.supported is True assert policy.arguments[0].callback.arguments[0].python_action is PythonBarrierAction.SCALAR_VALUE + + +def test_imported_interface_keeps_its_declaring_module_in_the_completed_identity(): + """A type an imported interface owns must not be attributed to the consumer. + + The consuming module never imports ``point_t``, so an identity taken from + the consuming scope names a type that module does not define and no wrapper + definition can satisfy it. + """ + sources = { + "callback_types.f90": """ +module callback_types + implicit none + type :: point_t + real(8) :: x + end type point_t + + abstract interface + subroutine move_point(p) + import :: point_t + implicit none + type(point_t), intent(inout) :: p + end subroutine move_point + end interface +end module callback_types +""", + "consumer.f90": """ +module consumer + use callback_types, only : move_point + implicit none +contains + subroutine run(f) + procedure(move_point) :: f + end subroutine run +end module consumer +""", + } + parsed = parse_fortran_project(sources) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="merged") + complete_semantic_policies(module) + + function = next(item for item in module.functions if item.name == "run") + policy = completed_function_wrapper_policy(function) + + assert policy.supported is True + assert policy.arguments[0].callback.arguments[0].derived_type_identity == ("callback_types", "point_t") diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 8508fd412..790a1b47b 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -3,7 +3,7 @@ from prik.parsers.fortran import parse_fortran_project from prik.printers import emit_module from prik.semantics.fortran2ir import FortranToIRConverter -from prik.semantics.models import UNRESOLVED_PROCEDURE_INTERFACE_METADATA +from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, UNRESOLVED_PROCEDURE_INTERFACE_METADATA from prik.semantics.native_contract import native_contract_issues from tests.fortran._support.semantic_conversion import get_function from prik.parsers.fortran import parse_fortran_file as parse_fortran_source @@ -263,3 +263,103 @@ def test_named_but_undeclared_procedure_interface_is_recorded_for_diagnosis(): callback = get_function(module, "minimize").arguments[0].semantic_type assert callback.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] == "OBJ" + + +CALLBACK_TYPES_SOURCE = """ +module callback_types + implicit none + type :: point_t + real(8) :: x + end type point_t + + abstract interface + subroutine move_point(p) + import :: point_t + implicit none + type(point_t), intent(inout) :: p + end subroutine move_point + end interface +end module callback_types +""" + + +def test_imported_interface_resolves_its_types_in_the_declaring_module(): + """An interface body is written in the scope of the module that declares it. + + The consuming module need not import the types the interface names, so + those types must keep the declaring module's identity rather than being + attributed to whichever module imported the interface. + """ + consumer_source = """ +module consumer + use callback_types, only : move_point + implicit none +contains + subroutine run(f) + procedure(move_point) :: f + end subroutine run +end module consumer +""" + project = parse_fortran_project({"callback_types.f90": CALLBACK_TYPES_SOURCE, "consumer.f90": consumer_source}) + modules = {module.name: module for module in FortranToIRConverter().visit(project)} + + callback = get_function(modules["consumer"], "run").arguments[0].semantic_type + point = callback.metadata["callback_arguments"][0].semantic_type + assert point.name == "point_t" + assert point.metadata[EXTERNAL_TYPE_REF_METADATA]["origin_module"] == "callback_types" + + +def test_procedure_local_use_resolves_an_imported_interface(): + """A ``use`` inside one procedure names the interface only in that scope.""" + source = """ +module proclocal_mod + implicit none +contains + subroutine run_local(callback) + use callback_types, only : move_point + implicit none + procedure(move_point) :: callback + end subroutine run_local +end module proclocal_mod +""" + project = parse_fortran_project({"callback_types.f90": CALLBACK_TYPES_SOURCE, "users.f90": source}) + modules = {module.name: module for module in FortranToIRConverter().visit(project)} + + callback = get_function(modules["proclocal_mod"], "run_local").arguments[0].semantic_type + assert callback.name == "move_point" + assert callback.storage is not None and callback.storage.kind == "callback" + + +def test_reexported_interface_resolves_through_every_import_hop(): + """An interface published by a re-exporting module resolves to its declarer.""" + reexport_source = """ +module reexport_mod + use callback_types, only : move_point + implicit none + public :: move_point +end module reexport_mod +""" + chain_source = """ +module chain_mod + use reexport_mod, only : move_point + implicit none +contains + subroutine run_chain(callback) + procedure(move_point) :: callback + end subroutine run_chain +end module chain_mod +""" + project = parse_fortran_project( + { + "callback_types.f90": CALLBACK_TYPES_SOURCE, + "reexport.f90": reexport_source, + "chain.f90": chain_source, + } + ) + modules = {module.name: module for module in FortranToIRConverter().visit(project)} + + callback = get_function(modules["chain_mod"], "run_chain").arguments[0].semantic_type + assert callback.name == "move_point" + assert callback.storage is not None and callback.storage.kind == "callback" + point = callback.metadata["callback_arguments"][0].semantic_type + assert point.metadata[EXTERNAL_TYPE_REF_METADATA]["origin_module"] == "callback_types" From b0364b8d261eda1140d4830fc26f60a507ab366f Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 02:42:11 +0100 Subject: [PATCH 05/24] Carry interface provenance through results, renames, and accessibility Three gaps remained in how an imported callback interface carries its source facts. Declaring-module ownership was recorded only while iterating dummies, so a function interface returning a type its own module declares attributed that type to the consuming module and failed to build. The helper now takes a semantic type and its declaration rather than an argument, and both the dummies and the result use it. Binding a prototype reference from a contract records the same origin, so the generated `.pyi` builds too. A renamed import kept only the local spelling, so the reference named an interface the declaring module never defines and the contract imported a name that does not exist there. The resolver now carries the local spelling beside the declaring signature, through any number of re-export hops, and the reference records both. A reference differing from the declaration only in case is the same interface, so it is spelled canonically rather than binding a second name. Following a re-export ignored Fortran accessibility, so a module that imported an interface privately still appeared to publish it. Reaching names from another module now applies that module's own visibility rules, at every hop; a module still sees its own private interfaces. Not addressed here: resolving a cross-module derived type through the runtime namespace. A wrapper looks the type up on the module owning the function rather than the one declaring the type, which also affects an ordinary function returning an imported type and predates this branch. The callback-result regression therefore asserts the build, not a call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 14 ++ prik/semantics/fortran2ir.py | 67 ++++-- prik/semantics/pyi2ir.py | 28 +++ .../test_multi_file_contract_generation.py | 109 ++++++++++ .../callbacks/policy/test_callback_policy.py | 43 ++++ .../test_fortran_callback_semantics.py | 205 +++++++++++++++++- 6 files changed, 451 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcbedd54..cc30bbd42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ release tags add a leading `v` to the package version. ## Unreleased +- A callback interface's result now keeps the declaring module's type identity, + matching its dummies. An imported function interface returning a type its own + module declares previously attributed that type to the consuming module and + failed to build, both from Fortran source and from a generated contract. + +- A renamed callback import keeps the declared interface name beside the local + one, so a contract imports `OBJ as LOCAL_OBJ` rather than a name the declaring + module never defines. A reference that differs from the declaration only in + case is now spelled canonically instead of binding a second name. + +- Following a re-exported callback interface respects Fortran accessibility. A + module that imports an interface privately no longer exposes it to a later + `use`, and the rule applies at every hop of a chain. + - An abstract interface imported from another module now converts in the scope of the module that declares it. A derived type the interface names belongs to that module, so wrapping a consumer that imports only the interface — and not diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 8cdcaafbc..2042db6fe 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -17,7 +17,7 @@ from collections.abc import Iterable from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, replace import re from pathlib import Path @@ -185,6 +185,18 @@ class _CallbackInterface: signature: FortranProcedureSignature module: FortranModule | None = None + local_name: str | None = None + """Spelling the importing scope binds, when a ``use`` renamed the interface.""" + + @property + def native_name(self) -> str: + """Return the name the declaring module gives this interface.""" + return self.signature.name + + @property + def visible_name(self) -> str: + """Return the canonical spelling visible where the interface was resolved.""" + return self.local_name or self.signature.name @dataclass(frozen=True) @@ -714,6 +726,7 @@ def _module_callback_interfaces( module: FortranModule, *, seen: frozenset[str] = frozenset(), + exported_only: bool = False, ) -> dict[str, _CallbackInterface]: """Index every interface name visible in one module. @@ -722,6 +735,10 @@ def _module_callback_interfaces( ``use`` hops resolves to the module that actually declares it. A module outside the index leaves its names unresolved, which later stages report against the ``use`` that named them. + + ``exported_only`` applies the module's accessibility to the result, for + a caller reaching the names from outside through ``use``. A module + still sees its own private interfaces, so it is left off in that case. """ key = module.name.casefold() if key in seen: @@ -734,7 +751,13 @@ def _module_callback_interfaces( seen=seen | {key}, override=False, ) - return visible + if not exported_only: + return visible + return { + name: resolved + for name, resolved in visible.items() + if cls._symbol_visibility(module, resolved.visible_name) == "public" + } @classmethod def _scope_callback_interfaces( @@ -769,12 +792,17 @@ def _merge_imported_callback_interfaces( source_module = modules.get(module_name.casefold()) if source_module is None: continue - source_lookup = cls._module_callback_interfaces(modules, source_module, seen=seen) + source_lookup = cls._module_callback_interfaces( + modules, + source_module, + seen=seen, + exported_only=True, + ) imported = ( source_lookup if not mappings else { - mapping.local_name.casefold(): resolved + mapping.local_name.casefold(): replace(resolved, local_name=mapping.local_name) for mapping in mappings if (resolved := source_lookup.get(mapping.source.casefold())) is not None } @@ -828,7 +856,7 @@ def _callback_semantic_type( self._normalize_callback_reference_storage(callback_argument, source_argument) self._record_prototype_argument_intent(callback_argument, source_argument) self._record_imported_prototype_type_origin( - callback_argument, + callback_argument.semantic_type, source_argument, resolved, derived_type_context, @@ -838,17 +866,29 @@ def _callback_semantic_type( if signature.result else SemanticType("None", dtype="None") ) + # A result carries the declaring module's types exactly as a dummy does. + self._record_imported_prototype_type_origin( + callback_return, + signature.result, + resolved, + derived_type_context, + ) prototype_module = str(signature.module or "") + # The declaring module names the interface; the importing scope may bind + # a different spelling. Both are source facts, and a contract needs each + # of them to import the right name under the right alias. + native_name = resolved.native_name if resolved is not None else interface_name + local_name = resolved.visible_name if resolved is not None else interface_name return SemanticType( - interface_name, + local_name, dtype="Prototype", metadata={ "arguments": [item.semantic_type for item in callback_arguments], "callback_arguments": callback_arguments, "return": callback_return, PROTOTYPE_REF_METADATA: { - "name": interface_name, - "local_name": interface_name, + "name": native_name, + "local_name": local_name, "origin_module": prototype_module, }, "native_callback_kind": signature.kind, @@ -934,8 +974,8 @@ def _is_written_back_callback_scalar( def _record_imported_prototype_type_origin( self, - callback_argument: SemanticArgument, - source_argument: FortranArgument | FortranVariable, + semantic_type: SemanticType, + declaration: FortranArgument | FortranVariable | None, resolved: _CallbackInterface | None, consuming_context: _DerivedTypeContext | None, ) -> None: @@ -946,18 +986,17 @@ def _record_imported_prototype_type_origin( origin keeps the identity with the module that declares the type rather than the one that happened to import the interface. """ - if resolved is None or resolved.module is None: + if resolved is None or resolved.module is None or declaration is None: return - if str(getattr(source_argument, "base_type", "")).casefold() != "derived": + if str(getattr(declaration, "base_type", "")).casefold() != "derived": return declaring = resolved.module.name consuming = str(consuming_context.module or "") if consuming_context is not None else "" if declaring.casefold() == consuming.casefold(): return - semantic_type = callback_argument.semantic_type if EXTERNAL_TYPE_REF_METADATA in semantic_type.metadata: return - name = str(getattr(source_argument, "kind", "") or semantic_type.name) + name = str(getattr(declaration, "kind", "") or semantic_type.name) wrapped = (declaring.casefold(), name.casefold()) in self.wrapped_derived_types semantic_type.metadata[EXTERNAL_TYPE_REF_METADATA] = { "name": name, diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 4fa6ce1ca..bff3f3e51 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -3842,17 +3842,41 @@ def _relative_imported_namespace(module_name: str, source_name: str) -> str: return f"{module_path}.{source_name}" +def _record_declaring_module_for_prototype_type( + semantic_type: SemanticType, + declaring_module: str, + declared_types: frozenset[str], +) -> None: + """Name the declaring module for a derived type a referenced prototype owns.""" + if not declaring_module or semantic_type.name not in declared_types: + return + if EXTERNAL_TYPE_REF_METADATA in semantic_type.metadata: + return + semantic_type.metadata[EXTERNAL_TYPE_REF_METADATA] = { + "name": semantic_type.name, + "local_name": semantic_type.name, + "origin_module": declaring_module, + } + + def _bind_prototype_reference( semantic_type: SemanticType, prototype: SemanticPrototype, *, origin_module: str, source_name: str, + declared_types: frozenset[str] = frozenset(), ) -> None: """Complete one type annotation as a named callback prototype reference.""" local_name = semantic_type.name arguments = deepcopy(prototype.arguments) return_type = deepcopy(prototype.return_type) or SemanticType("None", dtype="None") + # The prototype's own types are written in the declaring module's scope, so + # a type local to that module keeps its origin when the reference is copied + # into a module that only imported the interface. + declaring_module = str(prototype.origin.native_scope or origin_module) + for value in (*(argument.semantic_type for argument in arguments), return_type): + _record_declaring_module_for_prototype_type(value, declaring_module, declared_types) semantic_type.dtype = "Prototype" semantic_type.metadata = { "arguments": [argument.semantic_type for argument in arguments], @@ -3935,6 +3959,9 @@ def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[Semantic pipeline chaining; absent external definitions remain opaque references. """ definitions = {(module.name, declaration.name): declaration for module in modules for declaration in module.classes} + declared_class_names = { + module.name: frozenset(declaration.name for declaration in module.classes) for module in modules + } prototypes = _prototypes_with_reexports(modules) functions = {(module.name, function.name): function for module in modules for function in module.functions} for module in modules: @@ -3964,6 +3991,7 @@ def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[Semantic prototype, origin_module=str(prototype.origin.native_scope or origin_module.lstrip(".")), source_name=source_name, + declared_types=declared_class_names.get(str(prototype.origin.native_scope or ""), frozenset()), ) continue declaration = definitions.get((ref.get("origin_module"), ref.get("name"))) diff --git a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py index 14845ce53..92d645566 100644 --- a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py +++ b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py @@ -58,6 +58,20 @@ call calfun(x, f) end subroutine minimize_renamed end module renamed_mod + +module scoped_rename_mod + implicit none +contains + subroutine minimize_scoped(calfun, x, f) + use, non_intrinsic :: pintrf_mod, only : SCOPED_OBJ => OBJ + implicit none + procedure(SCOPED_OBJ) :: calfun + real(8), intent(in) :: x + real(8), intent(out) :: f + + call calfun(x, f) + end subroutine minimize_scoped +end module scoped_rename_mod """ @@ -111,6 +125,13 @@ def test_multi_file_generation_places_the_prototype_with_its_declaring_module(tm assert "from pintrf_mod import OBJ as LOCAL_OBJ" in renamed assert "calfun: LOCAL_OBJ" in renamed + # A procedure-local rename reaches the contract through the synthetic + # prototype import rather than the module's own import list. + scoped = (contracts / "scoped_rename_mod.pyi").read_text(encoding="utf-8") + assert "from pintrf_mod import OBJ as SCOPED_OBJ" in scoped + assert "calfun: SCOPED_OBJ" in scoped + assert "import SCOPED_OBJ" not in scoped.replace("OBJ as SCOPED_OBJ", "") + def test_generated_multi_file_contracts_parse_without_native_contract_issues(tmp_path: Path): """PRIK must be able to read back every contract it just wrote.""" @@ -140,3 +161,91 @@ def objective(x, f): assert module.solver_mod.minimize(objective, np.float64(3.0)) == np.float64(9.0) assert module.renamed_mod.minimize_renamed(objective, np.float64(4.0)) == np.float64(16.0) + assert module.scoped_rename_mod.minimize_scoped(objective, np.float64(5.0)) == np.float64(25.0) + + +CALLBACK_RESULT_TYPES_SOURCE = """ +module cbresult_types + implicit none + type :: point_t + real(8) :: x + end type point_t + + abstract interface + function make_point(x) result(p) + import :: point_t + implicit none + real(8), intent(in) :: x + type(point_t) :: p + end function make_point + end interface +end module cbresult_types +""" + +CALLBACK_RESULT_CONSUMER_SOURCE = """ +module cbresult_consumer + use, non_intrinsic :: cbresult_types, only : make_point, point_t + implicit none +contains + subroutine run(f, seed, out_x) + procedure(make_point) :: f + real(8), intent(in) :: seed + real(8), intent(out) :: out_x + type(point_t) :: made + + made = f(seed) + out_x = made%x + end subroutine run +end module cbresult_consumer +""" + + +def test_imported_callback_returning_a_module_owned_type_builds(tmp_path: Path): + """A callback result type belongs to the module that declares the interface. + + Attributing it to the consuming module produced an identity no wrapper + definition could satisfy, so the build failed outright. The generated + contract must name the declaring module and the extension must build. + + The built extension is not called here: resolving a cross-module derived + type through the runtime namespace is a separate, pre-existing gap that + also affects ordinary functions returning an imported type. + """ + sources = [] + for name, text in ( + ("cbresult_types.f90", CALLBACK_RESULT_TYPES_SOURCE), + ("cbresult_consumer.f90", CALLBACK_RESULT_CONSUMER_SOURCE), + ): + path = tmp_path / name + path.write_text(text, encoding="utf-8") + sources.append(path) + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + *[str(path) for path in sources], + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + + declaring = (contracts / "cbresult_types.pyi").read_text(encoding="utf-8") + assert "def make_point(" in declaring + assert "-> point_t: ..." in declaring + + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(path) for path in sources], + output_dir=tmp_path / "build", + output_name="callback_result_types", + ) + assert result.shared_library.exists() diff --git a/tests/fortran/callbacks/policy/test_callback_policy.py b/tests/fortran/callbacks/policy/test_callback_policy.py index 750d21852..3689016b0 100644 --- a/tests/fortran/callbacks/policy/test_callback_policy.py +++ b/tests/fortran/callbacks/policy/test_callback_policy.py @@ -260,3 +260,46 @@ def test_imported_interface_keeps_its_declaring_module_in_the_completed_identity assert policy.supported is True assert policy.arguments[0].callback.arguments[0].derived_type_identity == ("callback_types", "point_t") + + +def test_imported_interface_result_keeps_its_declaring_module_in_the_completed_identity(): + """A callback result's type identity must name the module that declares it.""" + sources = { + "callback_types.f90": """ +module callback_types + implicit none + type :: point_t + real(8) :: x + end type point_t + + abstract interface + function make_point(x) result(p) + import :: point_t + implicit none + real(8), intent(in) :: x + type(point_t) :: p + end function make_point + end interface +end module callback_types +""", + "consumer.f90": """ +module consumer + use callback_types, only : make_point + implicit none +contains + subroutine run(f) + procedure(make_point) :: f + end subroutine run +end module consumer +""", + } + parsed = parse_fortran_project(sources) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="merged") + complete_semantic_policies(module) + + policy = completed_function_wrapper_policy(next(item for item in module.functions if item.name == "run")) + + assert policy.supported is True + assert policy.arguments[0].callback.result.transfer.derived_type_identity == ("callback_types", "point_t") diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 790a1b47b..44a8f0b8e 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -3,7 +3,11 @@ from prik.parsers.fortran import parse_fortran_project from prik.printers import emit_module from prik.semantics.fortran2ir import FortranToIRConverter -from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, UNRESOLVED_PROCEDURE_INTERFACE_METADATA +from prik.semantics.models import ( + EXTERNAL_TYPE_REF_METADATA, + PROTOTYPE_REF_METADATA, + UNRESOLVED_PROCEDURE_INTERFACE_METADATA, +) from prik.semantics.native_contract import native_contract_issues from tests.fortran._support.semantic_conversion import get_function from prik.parsers.fortran import parse_fortran_file as parse_fortran_source @@ -363,3 +367,202 @@ def test_reexported_interface_resolves_through_every_import_hop(): assert callback.storage is not None and callback.storage.kind == "callback" point = callback.metadata["callback_arguments"][0].semantic_type assert point.metadata[EXTERNAL_TYPE_REF_METADATA]["origin_module"] == "callback_types" + + +MAKE_POINT_SOURCE = """ +module callback_types + implicit none + type :: point_t + real(8) :: x + end type point_t + + abstract interface + function make_point(x) result(p) + import :: point_t + implicit none + real(8), intent(in) :: x + type(point_t) :: p + end function make_point + end interface +end module callback_types +""" + + +def test_imported_interface_result_keeps_the_declaring_module(): + """A callback result carries the declaring module's types like a dummy does. + + Ownership was recorded only while iterating dummies, so a function + interface returning a module-owned type attributed it to the consumer. + """ + consumer_source = """ +module consumer + use callback_types, only : make_point + implicit none +contains + subroutine run(f) + procedure(make_point) :: f + end subroutine run +end module consumer +""" + project = parse_fortran_project({"callback_types.f90": MAKE_POINT_SOURCE, "consumer.f90": consumer_source}) + modules = {module.name: module for module in FortranToIRConverter().visit(project)} + + callback = get_function(modules["consumer"], "run").arguments[0].semantic_type + result = callback.metadata["return"] + assert result.name == "point_t" + assert result.metadata[EXTERNAL_TYPE_REF_METADATA]["origin_module"] == "callback_types" + + +def test_procedure_local_rename_keeps_both_the_declared_and_local_names(): + """A renamed import binds a new name without changing the declared one. + + The contract must import the declaring name under the local alias, which + requires keeping the two spellings apart as separate source facts. + """ + source = """ +module ren_types + implicit none + abstract interface + subroutine OBJ(x) + implicit none + real(8), intent(in) :: x + end subroutine OBJ + end interface +end module ren_types + +module ren_consumer + implicit none +contains + subroutine run_ren(callback) + use ren_types, only : LOCAL_OBJ => OBJ + implicit none + procedure(LOCAL_OBJ) :: callback + end subroutine run_ren +end module ren_consumer +""" + + module = FortranToIRConverter().visit(parse_fortran_source(source))[1] + + callback = get_function(module, "run_ren").arguments[0].semantic_type + assert callback.name == "LOCAL_OBJ" + assert callback.metadata[PROTOTYPE_REF_METADATA] == { + "name": "OBJ", + "local_name": "LOCAL_OBJ", + "origin_module": "ren_types", + } + + +def test_interface_reference_uses_the_declared_spelling(): + """Fortran matches names case-insensitively; Python contracts do not. + + A reference spelled in another case is the same interface, so the contract + keeps the declared spelling instead of binding a second name. + """ + source = """ +module cas_mod + implicit none + abstract interface + subroutine OBJ(x) + implicit none + real(8), intent(in) :: x + end subroutine OBJ + end interface +contains + subroutine run_cas(callback) + procedure(obj) :: callback + end subroutine run_cas +end module cas_mod +""" + + module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + + assert get_function(module, "run_cas").arguments[0].semantic_type.name == "OBJ" + + +ACCESSIBILITY_SOURCE = """ +module acc_a + implicit none + abstract interface + subroutine OBJ(x) + implicit none + real(8), intent(in) :: x + end subroutine OBJ + end interface +end module acc_a + +module acc_b_public + use acc_a, only : OBJ + implicit none + private + public :: OBJ +end module acc_b_public + +module acc_b_private + use acc_a, only : OBJ + implicit none + private +end module acc_b_private + +module acc_ok + use acc_b_public, only : OBJ + implicit none +contains + subroutine run_ok(callback) + procedure(OBJ) :: callback + end subroutine run_ok +end module acc_ok + +module acc_bad + use acc_b_private, only : OBJ + implicit none +contains + subroutine run_bad(callback) + procedure(OBJ) :: callback + end subroutine run_bad +end module acc_bad +""" + + +def _is_resolved_callback(module, function_name: str) -> bool: + semantic_type = get_function(module, function_name).arguments[0].semantic_type + return semantic_type.storage is not None and semantic_type.storage.kind == "callback" + + +def test_reexported_interface_resolves_only_when_the_module_publishes_it(): + """Following a re-export must respect the module's own accessibility. + + A name a module imports privately is not part of its interface, so reaching + it through ``use`` must not resolve even though the chain exists. + """ + modules = { + module.name: module for module in FortranToIRConverter().visit(parse_fortran_source(ACCESSIBILITY_SOURCE)) + } + + assert _is_resolved_callback(modules["acc_ok"], "run_ok") + assert not _is_resolved_callback(modules["acc_bad"], "run_bad") + + +def test_accessibility_is_enforced_at_every_re_export_hop(): + """A private hop anywhere in the chain stops the name from travelling.""" + source = ( + ACCESSIBILITY_SOURCE + + """ +module acc_mid + use acc_b_public, only : OBJ + implicit none + private +end module acc_mid + +module acc_far + use acc_mid, only : OBJ + implicit none +contains + subroutine run_far(callback) + procedure(OBJ) :: callback + end subroutine run_far +end module acc_far +""" + ) + modules = {module.name: module for module in FortranToIRConverter().visit(parse_fortran_source(source))} + + assert not _is_resolved_callback(modules["acc_far"], "run_far") From 9623e8c38eef6d0649398bfc572b02c4b61a9a52 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 03:09:06 +0100 Subject: [PATCH 06/24] Spell runtime extents the way a contract spells them in diagnostics Two bridge diagnostics interpolated extent expressions straight from the plan, so rejecting a strided callback array result reported extents ['::Strided'] -- the explicit step the IR stores -- rather than the shorthand the author wrote. `Strided` is a public contract name, so `T[::Strided]` and `T[::]` are two spellings of one contract while `T[:]` is the distinct contiguous one. The shorthand now has a single owner beside the marker set it belongs to, and the docstring builder reads it from there instead of keeping a private copy under a name that implied the explicit form was internal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 8 +++--- prik/codegen/docstrings.py | 7 +++--- prik/codegen/fortran/bridge.py | 12 ++++++--- prik/utilities/declaration_expressions.py | 16 ++++++++++++ .../codegen/test_callback_planning.py | 25 +++++++++++++++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc30bbd42..2ff71bdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,11 @@ release tags add a leading `v` to the package version. interface named by a procedure-local `use` is bound in the generated `.pyi` instead of appearing as a free name. -- Callback docstrings now state each array argument's rank and extents, and - every generated docstring spells a runtime extent the way the `.pyi` contract - spells it (`::`) rather than exposing the internal marker. +- Callback docstrings now state each array argument's rank and extents. Every + generated docstring and diagnostic spells a runtime extent with the shorthand + a contract uses (`Float64[::]`) rather than the explicit step the IR stores + (`Float64[::Strided]`); the two are the same contract, while `Float64[:]` + remains the distinct contiguous one. - A primitive scalar callback dummy the callee may write now reaches Python as rank-zero storage (`Out(Float64[()])`) instead of an independent value, so diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index c3c2eeffb..615c9aa3d 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -10,6 +10,7 @@ from __future__ import annotations from prik.codegen.primitive_scalar_types import NativeCArrayStorageRegistry +from prik.utilities.declaration_expressions import contract_extent_spelling from prik.policy.ownership import OwnershipOwner, PythonBarrierAction, SetterAction, TransferMode from prik.policy.models import ( ArrayPythonLayout, @@ -69,8 +70,6 @@ _LOGICAL_ARRAY_NOTE = "Fortran logical elements; compare with .astype(bool) rather than to 1." _UNKNOWN_EXTENTS = frozenset({"", ":", "::", "*", ".."}) -# A runtime extent is documented the way the `.pyi` contract spells it. -_PUBLIC_RUNTIME_EXTENTS = {"::Strided": "::"} class WrapperDocstringBuilder: @@ -1025,7 +1024,7 @@ def _callback_array_facts(array: ArrayHandoffPlan | None) -> tuple[str, ...]: if array is None or not array.rank: return () display = array.display_shape or array.shape - extents = ", ".join(_PUBLIC_RUNTIME_EXTENTS.get(str(extent), str(extent)) for extent in display) + extents = ", ".join(contract_extent_spelling(extent) for extent in display) return (f"rank {array.rank}",) + ((f"shape ({extents})",) if extents else ()) @staticmethod @@ -1052,7 +1051,7 @@ def _array_lines(array: ArrayHandoffPlan | None) -> tuple[str, ...]: lines = [WrapperDocstringBuilder._array_rank_line(array)] display_shape = array.display_shape or array.shape if display_shape and all(str(extent) not in _UNKNOWN_EXTENTS for extent in display_shape): - extents = (_PUBLIC_RUNTIME_EXTENTS.get(str(extent), str(extent)) for extent in display_shape) + extents = (contract_extent_spelling(extent) for extent in display_shape) lines.append(f" Shape: ({', '.join(extents)})") layout = WrapperDocstringBuilder._array_layout_label(array) if layout is not None: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index e19c7a825..ca90e5ecd 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -14,7 +14,11 @@ import re from prik.naming.native_symbols import NativeSymbolNames -from prik.utilities.declaration_expressions import RUNTIME_EXTENT_MARKERS, render_declaration_extent +from prik.utilities.declaration_expressions import ( + RUNTIME_EXTENT_MARKERS, + contract_extent_spelling, + render_declaration_extent, +) from prik.policy.ownership import ( AssignmentMode, CodegenAction, @@ -1144,7 +1148,7 @@ def _callback_result_shape(self, transfer: CallbackTransferPlan) -> str: spell. """ shape = self._callback_array_shape(transfer) - runtime = [expression for expression in shape if expression in RUNTIME_EXTENT_MARKERS] + runtime = [contract_extent_spelling(expression) for expression in shape if expression in RUNTIME_EXTENT_MARKERS] if runtime: raise ValueError( f"Callback array result {transfer.owner_path!r} has runtime extents {runtime} " @@ -8611,7 +8615,9 @@ def _procedure_prototype_result_shape(array: ArrayHandoffPlan | None, owner_path """Render a prototype function result's shape, which must be explicit.""" if array is None or array.rank is None: raise ValueError(f"Prototype value {owner_path!r} has no concrete shape") - runtime = [expression for expression in array.shape if expression in RUNTIME_EXTENT_MARKERS] + runtime = [ + contract_extent_spelling(expression) for expression in array.shape if expression in RUNTIME_EXTENT_MARKERS + ] if runtime: raise ValueError( f"Prototype result {owner_path!r} has runtime extents {runtime} " diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 924d24cde..7c40f37cb 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -26,6 +26,7 @@ "DeclarationExpressionCall", "ResolvedDeclarationExtent", "canonicalize_declaration_extent", + "contract_extent_spelling", "declaration_expression_call_sites", "declaration_expression_calls", "declaration_extent_references", @@ -45,6 +46,21 @@ # A runtime extent has a concrete rank but no compile-time bound, so a backend # spells it from the descriptor it is handed rather than from the expression. RUNTIME_EXTENT_MARKERS = frozenset({":", "::Strided", "Flat"}) +_SHORTHAND_EXTENT_SPELLINGS = {"::Strided": "::"} + + +def contract_extent_spelling(expression: str) -> str: + """Return the shorthand contract spelling for one extent expression. + + Some extents have two equivalent public spellings -- ``T[::Strided]`` names + the step explicitly and ``T[::]`` abbreviates it -- and the IR keeps the + explicit one. Generated contracts, docstrings and diagnostics read better + with the shorthand, so anything user-facing renders through this. Note + ``T[:]`` is a different contract, not a shorthand: it is contiguous. + """ + return _SHORTHAND_EXTENT_SPELLINGS.get(str(expression), str(expression)) + + _ASSUMED_RANK_MARKER = "..." _RUNTIME_DIMENSIONS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} _FORTRAN_RELATIONAL_OPERATORS = { diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 7dc3f8351..f8ee13e19 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -361,3 +361,28 @@ def test_callback_docstrings_carry_array_rank_and_public_extents(): assert "input : ndarray[float64], rank 2, shape (::, ::), intent(in)" in documentation assert "output : ndarray[float64], rank 2, shape (::, ::), intent(out)" in documentation assert "::Strided" not in documentation + + +def test_callback_array_result_diagnostic_uses_the_contract_spelling(): + """A rejected shape is reported the way a contract would spell it. + + A function result has no caller descriptor to measure, so a runtime extent + there is refused; the message names the extent the author wrote rather than + the explicit step the IR stores. + """ + module = pyi_text_to_semantic_module( + """ +from prik.contracts import Float64, In, prototype + +@prototype +def strided_result(x: In(Float64)) -> Float64[::]: ... + +def apply(callback: strided_result) -> None: ... +""", + module_name="callback_strided_result", + ) + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + + with pytest.raises(ValueError, match=r"runtime extents \['::'\]"): + _sources(plan) From e0f9a19c49fe2dc0e3b5f6196fed80c98fed854f Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 03:12:48 +0100 Subject: [PATCH 07/24] Extract prototype binding from external reference reconciliation Carrying the declaring module's classes into prototype binding pushed reconcile_external_type_refs to complexity 21, over the staged limit of 20. The prototype branch moves to its own function, which also lets the module name candidates reuse the helper the re-export index already uses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/pyi2ir.py | 59 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index bff3f3e51..5934548d4 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -3949,6 +3949,38 @@ def _prototypes_with_reexports(modules: list[SemanticModule]) -> dict[tuple[str, return resolved +def _bind_referenced_prototype( + semantic_type: SemanticType, + ref: dict[str, object], + prototypes: dict[tuple[str, str], SemanticPrototype], + declared_class_names: dict[str, frozenset[str]], +) -> bool: + """Complete one external reference as a prototype, reporting whether it matched.""" + origin_module = ref.get("origin_module") + source_name = ref.get("name") + if not isinstance(origin_module, str) or not isinstance(source_name, str): + return False + prototype = next( + ( + found + for candidate in _external_module_candidates(origin_module) + if (found := prototypes.get((candidate, source_name))) is not None + ), + None, + ) + if prototype is None: + return False + declaring_module = str(prototype.origin.native_scope or "") + _bind_prototype_reference( + semantic_type, + prototype, + origin_module=declaring_module or origin_module.lstrip("."), + source_name=source_name, + declared_types=declared_class_names.get(declaring_module, frozenset()), + ) + return True + + def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[SemanticModule]: """Resolve imported class and prototype references across converted modules. @@ -3969,31 +4001,8 @@ def reconcile_external_type_refs(modules: list[SemanticModule]) -> list[Semantic ref = semantic_type.metadata.get(EXTERNAL_TYPE_REF_METADATA) if not isinstance(ref, dict): continue - origin_module = ref.get("origin_module") - source_name = ref.get("name") - if isinstance(origin_module, str) and isinstance(source_name, str): - module_candidates = ( - origin_module, - origin_module.lstrip("."), - origin_module.lstrip(".").rsplit(".", 1)[-1], - ) - prototype = next( - ( - candidate_prototype - for candidate in module_candidates - if candidate and (candidate_prototype := prototypes.get((candidate, source_name))) is not None - ), - None, - ) - if prototype is not None: - _bind_prototype_reference( - semantic_type, - prototype, - origin_module=str(prototype.origin.native_scope or origin_module.lstrip(".")), - source_name=source_name, - declared_types=declared_class_names.get(str(prototype.origin.native_scope or ""), frozenset()), - ) - continue + if _bind_referenced_prototype(semantic_type, ref, prototypes, declared_class_names): + continue declaration = definitions.get((ref.get("origin_module"), ref.get("name"))) wrapped = declaration is not None and ( not isinstance(declaration, SemanticClass) or "Opaque" not in declaration.base_classes From eed7e66ed09e5ee907fc20169a239746b5336512 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 03:36:00 +0100 Subject: [PATCH 08/24] Remove the Strided contract name and the dimension step `T[::]` already spells a strided axis and `T[:]` a contiguous one, so the explicit `T[::Strided]` and `T[0:n:Strided]` forms were a second way to write contracts that already had one. The docs described `Strided` as a compatibility spelling for an older form and told authors to use the short one; it is now gone rather than carried. The step position spelled nothing else, so a value there is refused with a message naming the spelling to use instead. Without that check the removed form would still have parsed: its text happens to match the marker the IR carries for a strided axis, so dropping the contract name alone left it working for anyone who did not import the name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 +++++ docs/user/reference/pyi-format.md | 5 +--- prik/contracts/__init__.py | 2 -- prik/semantics/pyi2ir.py | 18 +++++++++----- .../semantics/test_types_and_values.py | 24 +++++++++++++------ 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff71bdca..c366eedea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- Removed the `Strided` contract name and the dimension step that carried it. + `T[::]` already spells a strided axis and `T[:]` a contiguous one, so the + longer `T[::Strided]` and `T[0:n:Strided]` forms are gone rather than kept as + a second way to write the same contract. A value in a dimension's step + position is now rejected with a message naming the spelling to use. + - A callback interface's result now keeps the declaring module's type identity, matching its dummies. An imported function interface returning a type its own module declares previously attributed that type to the consuming module and diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 071f0e403..9e47748fb 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -748,9 +748,6 @@ and supported pure specification functions. `size(values, 2)`, for example, becomes the second public extent. PRIK rejects expressions it cannot resolve before lowering. -`Strided` is a compatibility spelling for older explicit forms such as -`T[::Strided]`; author the shorter `T[::]` form. - ### Character Length And Shape `String` uses the first subscription for character length and a second @@ -994,7 +991,7 @@ valid and whether it is buildable. | Storage and result types | `Addr`, `Allocatable`, `Pointer`, `Returns`, `private` | | Compatibility/category types | `Matrix`, `Vector`, `OpaqueHandle`, `WrappedType` | | Class and C inspection markers | `CAnonymous`, `CAnonymousMember`, `CStruct`, `CUnion`, `Opaque` | -| Shape and layout markers | `Contiguous`, `COPY_F`, `Flat`, `ORDER_ANY`, `ORDER_C`, `ORDER_F`, `Strided` | +| Shape and layout markers | `Contiguous`, `COPY_F`, `Flat`, `ORDER_ANY`, `ORDER_C`, `ORDER_F` | | General metadata | `Aliased`, `ArrayCategory`, `AssumedType`, `FortranAllocatable`, `Immutable`, `MaybeUnallocated`, `Polymorphic`, `SourceName` | | Constraints and ownership | `Bounded`, `Finite`, `Range`, `Ownership`, `Transfer`, `Destruction`, `PointerAssociation`, `PointerPolicy` | | Prototype direction | `In`, `Out`, `InOut` | diff --git a/prik/contracts/__init__.py b/prik/contracts/__init__.py index c640da998..3741cac97 100644 --- a/prik/contracts/__init__.py +++ b/prik/contracts/__init__.py @@ -239,7 +239,6 @@ def apply(target): ORDER_F = _ContractExpression() Pointer = _DescriptorContract("pointer") Polymorphic = _ContractExpression() -Strided = _ContractExpression() Arg = _expression ArrayCategory = _expression @@ -411,7 +410,6 @@ def destroy(target): "Returns", "SizeT", "SourceName", - "Strided", "String", "Transfer", "UInt", diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 5934548d4..24efcbb91 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -2658,14 +2658,20 @@ def dimension_text(self, node: ast.expr) -> str: return expression def slice_text(self, node: ast.Slice) -> str: - """Render one dimension slice, preserving the contract's strided marker.""" + """Render one dimension slice as written. + + A dimension carries bounds only. The step position spells nothing the + contract grammar defines, so a value there is rejected rather than read + as an extent expression. + """ + if node.step is not None: + step = ast.unparse(node.step) + raise ValueError( + f"Array dimension step {step!r} is not part of the contract grammar; " + "write 'T[::]' for a strided axis or 'T[:]' for a contiguous one" + ) lower = "" if node.lower is None else ast.unparse(node.lower) upper = "" if node.upper is None else ast.unparse(node.upper) - step = "" - if node.step is not None: - step = _STRIDED_DIMENSION_SENTINEL if self.matches_name(node.step, "Strided") else ast.unparse(node.step) - if step: - return f"{lower}:{upper}:{step}" return f"{lower}:{upper}" # Callback and result conversion diff --git a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py index 8a3cbba51..fdb87ca9e 100644 --- a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py +++ b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py @@ -252,21 +252,31 @@ def apply( assert args["scratch"].source_shape == [] -def test_convert_pyi_to_ir_accepts_explicit_strided_marker_for_edited_contracts(): +def test_convert_pyi_to_ir_reads_a_strided_axis_from_its_empty_step(): + """An empty step marks a strided axis; a bounded axis keeps its bounds.""" module = parse_pyi_text( """ -current: Float64[::] -explicit: Float64[::Strided] +unbounded: Float64[::] bounded: Float64[0:n:] -explicit_bounded: Float64[0:n:Strided] """, module_name="strided_axes", ) arrays = [variable.semantic_type.storage.array for variable in module.variables] - assert [array.shape for array in arrays] == [["::Strided"], ["::Strided"], ["0:n:Strided"], ["0:n:Strided"]] - assert [array.axes for array in arrays] == [["strided"], ["strided"], ["strided"], ["strided"]] - assert [array.contiguous for array in arrays] == [False, False, False, False] + assert [array.shape for array in arrays] == [["::Strided"], ["0:n:Strided"]] + assert [array.axes for array in arrays] == [["strided"], ["strided"]] + assert [array.contiguous for array in arrays] == [False, False] + + +@pytest.mark.parametrize("dimension", ["Float64[::Strided]", "Float64[0:n:Strided]", "Float64[::2]"]) +def test_convert_pyi_to_ir_rejects_a_dimension_step(dimension: str): + """A dimension carries bounds only, so the step position spells nothing. + + `T[::]` already says strided, so the longer explicit form it replaced is + refused rather than kept as a second way to write the same contract. + """ + with pytest.raises(ValueError, match="not part of the contract grammar"): + parse_pyi_text(f"x: {dimension}\n", module_name="rejected_step") def test_convert_pyi_to_ir_uses_fortran_native_array_defaults(): From d362f6d24bcfb7a105ca99004fa5e0a2f430fab5 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 04:13:00 +0100 Subject: [PATCH 09/24] Carry a strided axis as the spelling a contract uses The IR named a strided axis after a contract name that no longer exists, so every layer that showed one to a reader translated it back: the `.pyi` printer, the docstring builder and two bridge diagnostics each converted the token to `::`. Producing `::` directly removes the translation and the mismatch behind it. The axis mode had been read from the word itself, so that rule moves beside the marker set it belongs to and states what actually marks a strided axis: a trailing empty step, with bounds (`lower:upper:`) or without (`::`). Six sites re-declared the runtime marker sets as literals; they now read the shared ones. The absence assertions in the callback planning tests went with the token -- `::` is Fortran's declaration separator, so its absence from generated source says nothing, and the positive spellings beside them already prove the lowering. `prik semantics` output changes with the IR, so its two expected payloads are regenerated. Contracts, docstrings and generated sources are byte for byte unchanged, having already printed the contract spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 ++++ prik/codegen/c/binding.py | 6 ++-- prik/codegen/docstrings.py | 7 ++--- prik/codegen/fortran/bridge.py | 12 ++------ prik/pipeline/wrapper.py | 3 +- prik/policy/construction.py | 5 ++-- prik/printers/pyi.py | 7 +---- prik/semantics/fortran2ir.py | 5 ++-- prik/semantics/pyi2ir.py | 2 +- prik/utilities/declaration_expressions.py | 30 +++++++++---------- .../arrays/semantics/test_array_semantics.py | 4 +-- .../test_declaration_expression_utilities.py | 6 ++-- .../codegen/test_callback_planning.py | 5 +--- .../test_fortran_callback_semantics.py | 2 +- .../semantics/test_types_and_storage.py | 4 +-- .../general/expected/modern_pyi_example.json | 4 +-- .../expected/procedures_and_functions.json | 8 ++--- .../semantics/test_calls_and_projections.py | 2 +- .../semantics/test_types_and_values.py | 4 +-- .../semantics/test_string_pyi_semantics.py | 2 +- 20 files changed, 58 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c366eedea..14a885f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- The semantic IR now carries a strided axis as `::`, the spelling a contract + uses, instead of a longer internal token. `prik semantics` output changes + accordingly; contracts, docstrings and generated sources are unaffected + because they already printed the contract spelling. + - Removed the `Strided` contract name and the dimension step that carried it. `T[::]` already spells a strided axis and `T[:]` a contiguous one, so the longer `T[::Strided]` and `T[0:n:Strided]` forms are gone rather than kept as diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 06655f213..b86c7febf 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -8239,7 +8239,7 @@ def _native_array_actual_shape_checks( nodes = [] for axis, expression in enumerate(actual.shape): if ( - expression in {":", "::Strided", "Flat"} + expression in RUNTIME_EXTENT_MARKERS or (actual.flatten_storage and axis == actual.flat_axis) or array.extent_evaluation[axis] == "bridge" ): @@ -8374,7 +8374,7 @@ def _array_shape_checks( if handoff is None or handoff.rank is None: return () checks = [] - runtime_markers = {":", "::Strided", "Flat"} + runtime_markers = RUNTIME_EXTENT_MARKERS for axis, expression in enumerate(handoff.shape): if expression in runtime_markers: continue @@ -8405,7 +8405,7 @@ def _descriptor_array_shape_checks( return () checks = [] for axis, expression in enumerate(handoff.shape): - if expression in {":", "::Strided", "Flat"} or handoff.extent_evaluation[axis] == "bridge": + if expression in RUNTIME_EXTENT_MARKERS or handoff.extent_evaluation[axis] == "bridge": continue expected = self._array_extent_expression(handoff, axis, expression, context) checks.append( diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 615c9aa3d..508d41de8 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -10,7 +10,6 @@ from __future__ import annotations from prik.codegen.primitive_scalar_types import NativeCArrayStorageRegistry -from prik.utilities.declaration_expressions import contract_extent_spelling from prik.policy.ownership import OwnershipOwner, PythonBarrierAction, SetterAction, TransferMode from prik.policy.models import ( ArrayPythonLayout, @@ -69,7 +68,7 @@ _LOGICAL_ARRAY_NOTE = "Fortran logical elements; compare with .astype(bool) rather than to 1." -_UNKNOWN_EXTENTS = frozenset({"", ":", "::", "*", ".."}) +_UNKNOWN_EXTENTS = frozenset({"", ":", "*", ".."}) class WrapperDocstringBuilder: @@ -1024,7 +1023,7 @@ def _callback_array_facts(array: ArrayHandoffPlan | None) -> tuple[str, ...]: if array is None or not array.rank: return () display = array.display_shape or array.shape - extents = ", ".join(contract_extent_spelling(extent) for extent in display) + extents = ", ".join(str(extent) for extent in display) return (f"rank {array.rank}",) + ((f"shape ({extents})",) if extents else ()) @staticmethod @@ -1051,7 +1050,7 @@ def _array_lines(array: ArrayHandoffPlan | None) -> tuple[str, ...]: lines = [WrapperDocstringBuilder._array_rank_line(array)] display_shape = array.display_shape or array.shape if display_shape and all(str(extent) not in _UNKNOWN_EXTENTS for extent in display_shape): - extents = (contract_extent_spelling(extent) for extent in display_shape) + extents = (str(extent) for extent in display_shape) lines.append(f" Shape: ({', '.join(extents)})") layout = WrapperDocstringBuilder._array_layout_label(array) if layout is not None: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index ca90e5ecd..e19c7a825 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -14,11 +14,7 @@ import re from prik.naming.native_symbols import NativeSymbolNames -from prik.utilities.declaration_expressions import ( - RUNTIME_EXTENT_MARKERS, - contract_extent_spelling, - render_declaration_extent, -) +from prik.utilities.declaration_expressions import RUNTIME_EXTENT_MARKERS, render_declaration_extent from prik.policy.ownership import ( AssignmentMode, CodegenAction, @@ -1148,7 +1144,7 @@ def _callback_result_shape(self, transfer: CallbackTransferPlan) -> str: spell. """ shape = self._callback_array_shape(transfer) - runtime = [contract_extent_spelling(expression) for expression in shape if expression in RUNTIME_EXTENT_MARKERS] + runtime = [expression for expression in shape if expression in RUNTIME_EXTENT_MARKERS] if runtime: raise ValueError( f"Callback array result {transfer.owner_path!r} has runtime extents {runtime} " @@ -8615,9 +8611,7 @@ def _procedure_prototype_result_shape(array: ArrayHandoffPlan | None, owner_path """Render a prototype function result's shape, which must be explicit.""" if array is None or array.rank is None: raise ValueError(f"Prototype value {owner_path!r} has no concrete shape") - runtime = [ - contract_extent_spelling(expression) for expression in array.shape if expression in RUNTIME_EXTENT_MARKERS - ] + runtime = [expression for expression in array.shape if expression in RUNTIME_EXTENT_MARKERS] if runtime: raise ValueError( f"Prototype result {owner_path!r} has runtime extents {runtime} " diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 9741cc561..e7a81ad39 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -20,6 +20,7 @@ from pathlib import Path import time +from prik.utilities.declaration_expressions import RUNTIME_DIMENSION_MARKERS from prik.utilities.stage_values import StageRecord from prik.policy.ownership import ( AssignmentMode, @@ -5048,7 +5049,7 @@ def _array_extent_evaluation_is_consistent(array: ArrayHandoffPlan) -> bool: def _array_result_extent_diagnostics(self, plan: ResultPlan) -> tuple[WrapperPlanDiagnostic, ...]: """Reject unresolved ordinary array result extent spellings.""" array = plan.array - if array is not None and any(shape in {":", "::Strided", "...", "Flat"} for shape in array.shape): + if array is not None and any(shape in RUNTIME_DIMENSION_MARKERS for shape in array.shape): return (self._diagnostic(plan.owner_path, "unresolved-array-result-shape", array.shape),) return () diff --git a/prik/policy/construction.py b/prik/policy/construction.py index b8d40f6e0..45737f052 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -173,6 +173,7 @@ FunctionWrapperPolicy, ) from prik.utilities.declaration_expressions import ( + RUNTIME_DIMENSION_MARKERS, declaration_expression_call_sites, declaration_extent_references, resolve_declaration_extent, @@ -5745,7 +5746,7 @@ def _ordinary_array_result_blockers( if decision.nullable or decision.descriptor_boundary: blockers.append(f"{label} is descriptor-backed or nullable") array = _array_handoff_policy(semantic_type) - if array is None or array.rank is None or any(shape in {":", "::Strided", "...", "Flat"} for shape in array.shape): + if array is None or array.rank is None or any(shape in RUNTIME_DIMENSION_MARKERS for shape in array.shape): blockers.append(f"{label} ordinary array shape is not fully expressible") elif array.native_order != array.order: blockers.append(f"{label} COPY_F applies only to Python-visible array arguments") @@ -7777,7 +7778,7 @@ def _is_phase6_raw_array_address_type(semantic_type: models.SemanticType) -> boo supported_element = _is_plan_primitive_value_type(semantic_type) or ( semantic_type.name == "String" and policy.itemsize is not None ) - return supported_element and all(item not in {":", "::Strided", "...", "Flat"} for item in policy.shape) + return supported_element and all(item not in RUNTIME_DIMENSION_MARKERS for item in policy.shape) def _is_raw_array_address_type(semantic_type: models.SemanticType) -> bool: diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 645d982e6..e83a7756f 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -664,12 +664,7 @@ def _canonical_array_dimension(dimension: object) -> str: @staticmethod def _printed_array_dimension(dimension: object) -> str: """Return the public `.pyi` spelling for an array dimension.""" - text = PyiPrinter._canonical_array_dimension(dimension) - if text == "::Strided": - return "::" - if text.endswith(":Strided"): - return text[: -len("Strided")] - return text + return PyiPrinter._canonical_array_dimension(dimension) @staticmethod def _array_annotation_metadata( diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 2042db6fe..f9fa37031 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -37,6 +37,7 @@ FortranVariable, ) from prik.utilities.declaration_expressions import ( + is_strided_extent, ArrayExpressionSource, canonicalize_declaration_extent, declaration_expression_calls, @@ -2269,7 +2270,7 @@ def _array_axes( if category == "assumed_rank": return ["..."] if category == "assumed_shape" and not contiguous: - return ["::Strided" for _dim in shape] + return ["::" for _dim in shape] axes: list[str] = [] for dim in shape: @@ -2326,7 +2327,7 @@ def _array_contiguous(category: str, *, contiguous: bool) -> bool | None: @staticmethod def _is_strided_axis(axis: str) -> bool: """Return whether an encoded public axis carries the strided marker.""" - return "Strided" in axis + return is_strided_extent(axis) @staticmethod def _reference_storage_contract(*, writes_argument: bool) -> SemanticStorageContract: diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 24efcbb91..3f617fa62 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -2235,7 +2235,7 @@ def _flat_array_dimensions( ) lower_bounds, upper_bounds = _PyiAstParser._bounds_from_source_shape(source_shape) return ( - [dim.replace(_STRIDED_DIMENSION_SENTINEL, "Strided") for dim in dims], + [dim.replace(_STRIDED_DIMENSION_SENTINEL, "") for dim in dims], None, source_shape, lower_bounds, diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 7c40f37cb..87d069cd5 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -21,12 +21,12 @@ from dataclasses import dataclass __all__ = ( + "RUNTIME_DIMENSION_MARKERS", "RUNTIME_EXTENT_MARKERS", "ArrayExpressionSource", "DeclarationExpressionCall", "ResolvedDeclarationExtent", "canonicalize_declaration_extent", - "contract_extent_spelling", "declaration_expression_call_sites", "declaration_expression_calls", "declaration_extent_references", @@ -35,6 +35,7 @@ "fortran_extent_to_python", "is_declaration_expression_helper", "is_public_declaration_expression", + "is_strided_extent", "render_declaration_extent", "resolve_declaration_extent", "split_declaration_assignment", @@ -45,24 +46,23 @@ # A runtime extent has a concrete rank but no compile-time bound, so a backend # spells it from the descriptor it is handed rather than from the expression. -RUNTIME_EXTENT_MARKERS = frozenset({":", "::Strided", "Flat"}) -_SHORTHAND_EXTENT_SPELLINGS = {"::Strided": "::"} +RUNTIME_EXTENT_MARKERS = frozenset({":", "::", "Flat"}) -def contract_extent_spelling(expression: str) -> str: - """Return the shorthand contract spelling for one extent expression. +def is_strided_extent(expression: str) -> bool: + """Return whether one extent expression describes a strided axis. - Some extents have two equivalent public spellings -- ``T[::Strided]`` names - the step explicitly and ``T[::]`` abbreviates it -- and the IR keeps the - explicit one. Generated contracts, docstrings and diagnostics read better - with the shorthand, so anything user-facing renders through this. Note - ``T[:]`` is a different contract, not a shorthand: it is contiguous. + A trailing empty step marks it, with or without bounds: ``::`` spans the + whole axis and ``lower:upper:`` narrows it. Without that step the axis is + contiguous, so ``:`` and ``lower:upper`` are dense. """ - return _SHORTHAND_EXTENT_SPELLINGS.get(str(expression), str(expression)) + parts = str(expression).split(":") + return len(parts) == 3 and parts[2] == "" _ASSUMED_RANK_MARKER = "..." -_RUNTIME_DIMENSIONS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} +# Every extent whose value only exists at run time, assumed rank included. +RUNTIME_DIMENSION_MARKERS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} _FORTRAN_RELATIONAL_OPERATORS = { ".eq.": "==", ".ne.": "!=", @@ -381,7 +381,7 @@ def resolve_declaration_extent( stored on completed policy and consumed by backend rendering. """ # Stage 1: preserve caller-owned runtime dimension markers. - if expression in _RUNTIME_DIMENSIONS: + if expression in RUNTIME_DIMENSION_MARKERS: return ResolvedDeclarationExtent(expression) # Stage 2: parse the public expression before binding any producer roles. @@ -416,7 +416,7 @@ def declaration_extent_references(expression: str) -> tuple[str, ...]: known. Array properties and unsupported syntax return ```` so the later policy stage cannot accidentally treat them as scalar values. """ - if expression in _RUNTIME_DIMENSIONS: + if expression in RUNTIME_DIMENSION_MARKERS: return () tree = _parse_expression(expression) if tree is None: @@ -1612,7 +1612,7 @@ def render_declaration_extent( """ if target not in {"c", "fortran"}: raise ValueError(f"unsupported declaration-expression target: {target!r}") - if expression in _RUNTIME_DIMENSIONS: + if expression in RUNTIME_DIMENSION_MARKERS: return expression try: node = ast.parse(expression, mode="eval").body diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index 75e96b557..83f35a5e5 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -43,7 +43,7 @@ def test_array_constraints(): contract = array_contract(x.semantic_type) assert contract.category == "assumed_shape" - assert contract.shape == ["::Strided"] + assert contract.shape == ["::"] assert contract.source_shape == [":"] assert contract.order is None @@ -76,7 +76,7 @@ def test_matrix_semantics(): assert A.semantic_type.rank == 2 contract = array_contract(A.semantic_type) - assert A.semantic_type.shape == ["::Strided", "::Strided"] + assert A.semantic_type.shape == ["::", "::"] assert contract.source_shape == [":", ":"] assert contract.category == "assumed_shape" assert contract.order == "ORDER_F" diff --git a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py index 416a96a53..14390ede1 100644 --- a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py +++ b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py @@ -34,7 +34,7 @@ def test_source_helpers_keep_nested_syntax_intact() -> None: "[third, fourth]", ] assert split_top_level_expression("'first''part', second", ",") == ["'first''part'", "second"] - assert split_top_level_expression("first::Strided:upper", ":") == ["first", "", "Strided", "upper"] + assert split_top_level_expression("first::middle:upper", ":") == ["first", "", "middle", "upper"] with pytest.raises(ValueError, match="one character"): split_top_level_expression("value", "::") @@ -111,7 +111,7 @@ def test_normalization_and_inspection_preserve_expression_provenance() -> None: assert declaration_extent_references("n + max(m, 1)") == ("n", "m") assert declaration_extent_references("values.shape[0]") == ("",) assert declaration_extent_references("not valid (") == ("",) - assert declaration_extent_references("::Strided") == () + assert declaration_extent_references("::") == () assert declaration_extent_uses_power("n ** 2") assert not declaration_extent_uses_power("not valid (") assert is_declaration_expression_helper("SUM") @@ -192,7 +192,7 @@ def test_role_resolution_reuses_completed_roles_and_names_blockers() -> None: array_roles = {"values": ("values", ("value_role_0", "value_role_1"))} callable_roles = {"extent_for": ("prik_extent_for", "extent_role")} - assert resolve_declaration_extent("::Strided", scalar_roles, array_roles) == ResolvedDeclarationExtent("::Strided") + assert resolve_declaration_extent("::", scalar_roles, array_roles) == ResolvedDeclarationExtent("::") assert resolve_declaration_extent("n + values.shape[1]", scalar_roles, array_roles) == ResolvedDeclarationExtent( "n + __prik_extent_values_1", ("n", "__prik_extent_values_1"), diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index f8ee13e19..740b36bb0 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -264,10 +264,9 @@ def test_runtime_callback_extents_lower_to_assumed_shape_dummies_and_measured_co plan = WrapperPlanner().build(module) callback = _callback_argument(plan, "apply_assumed_shape").callback - assert [transfer.array.shape for transfer in callback.arguments] == [("::Strided",), ("::Strided",)] + assert [transfer.array.shape for transfer in callback.arguments] == [("::",), ("::",)] _, bridge = _sources(plan) - assert "::Strided" not in bridge assert "real(c_double), intent(in), dimension(:) :: values" in bridge assert "real(c_double), target, dimension(size(values, 1)) :: values_callback_storage" in bridge assert "real(c_double), intent(out), dimension(:) :: doubled" in bridge @@ -340,7 +339,6 @@ def test_multidimensional_runtime_extents_measure_every_axis_from_the_dummy(): assert [transfer.array.rank for transfer in callback.arguments] == [2, 2] _, bridge = _sources(plan) - assert "::Strided" not in bridge assert "real(c_double), intent(in), dimension(:, :) :: input" in bridge assert "real(c_double), target, dimension(size(input, 1), size(input, 2)) :: input_callback_storage" in bridge assert "real(c_double), intent(out), dimension(:, :) :: output" in bridge @@ -360,7 +358,6 @@ def test_callback_docstrings_carry_array_rank_and_public_extents(): assert "Called as: callback(input, output) -> None" in documentation assert "input : ndarray[float64], rank 2, shape (::, ::), intent(in)" in documentation assert "output : ndarray[float64], rank 2, shape (::, ::), intent(out)" in documentation - assert "::Strided" not in documentation def test_callback_array_result_diagnostic_uses_the_contract_spelling(): diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 44a8f0b8e..3df05bff3 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -245,7 +245,7 @@ def test_imported_abstract_interface_resolves_across_files_and_keeps_its_declare assert callback.name == "OBJ" assert callback.storage is not None and callback.storage.kind == "callback" assert [argument.name for argument in callback.metadata["callback_arguments"]] == ["x", "f"] - assert callback.metadata["arguments"][0].shape == ["::Strided"] + assert callback.metadata["arguments"][0].shape == ["::"] assert callback.metadata["return"].name == "None" diff --git a/tests/fortran/data_types/semantics/test_types_and_storage.py b/tests/fortran/data_types/semantics/test_types_and_storage.py index 893b27c7b..054f1f10c 100644 --- a/tests/fortran/data_types/semantics/test_types_and_storage.py +++ b/tests/fortran/data_types/semantics/test_types_and_storage.py @@ -157,7 +157,7 @@ def test_fortran_native_storage_contracts_cover_array_categories_and_scalars(): assumed = array_contract(args["assumed"].semantic_type) assert assumed.category == "assumed_shape" - assert assumed.shape == ["::Strided", "::Strided"] + assert assumed.shape == ["::", "::"] assert assumed.order == "ORDER_F" contig = array_contract(args["contig"].semantic_type) @@ -268,7 +268,7 @@ def test_fortran_native_storage_contracts_preserve_exact_bounds_and_member_flags assert semantic_member.semantic_type.storage.array.pointer is True assert plain_member.optional is False assert plain_member.visibility == "public" - assert plain_member.semantic_type.storage.array.shape == ["::Strided"] + assert plain_member.semantic_type.storage.array.shape == ["::"] assert plain_member.semantic_type.storage.array.allocatable is False assert plain_member.semantic_type.storage.array.pointer is False assert plain_member.origin.source_language == "fortran" diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json index b6dcc3b14..515aec6c5 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json @@ -958,7 +958,7 @@ "rank": 1, "dtype": "Float64", "shape": [ - "::Strided" + "::" ], "constraints": [], "coercions": [], @@ -977,7 +977,7 @@ "array": { "rank": 1, "shape": [ - "::Strided" + "::" ], "lower_bounds": [], "upper_bounds": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json index 638bca564..4ea1363d2 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json @@ -14,7 +14,7 @@ "rank": 1, "dtype": "Float64", "shape": [ - "::Strided" + "::" ], "constraints": [], "coercions": [], @@ -33,7 +33,7 @@ "array": { "rank": 1, "shape": [ - "::Strided" + "::" ], "lower_bounds": [], "upper_bounds": [], @@ -269,7 +269,7 @@ "rank": 1, "dtype": "Float64", "shape": [ - "::Strided" + "::" ], "constraints": [], "coercions": [], @@ -288,7 +288,7 @@ "array": { "rank": 1, "shape": [ - "::Strided" + "::" ], "lower_bounds": [], "upper_bounds": [], diff --git a/tests/fortran/infrastructure/semantic_pyi/semantics/test_calls_and_projections.py b/tests/fortran/infrastructure/semantic_pyi/semantics/test_calls_and_projections.py index e6ba0bc49..74b3b17fa 100644 --- a/tests/fortran/infrastructure/semantic_pyi/semantics/test_calls_and_projections.py +++ b/tests/fortran/infrastructure/semantic_pyi/semantics/test_calls_and_projections.py @@ -872,7 +872,7 @@ def test_convert_pyi_to_ir_handles_pointer_and_array_storage_variants(): assert rank_any.storage.array.category == "assumed_rank" assert rank_any.storage.array.source_shape == [".."] assert rank_any.rank == 1 - assert strided.shape == ["0:n:Strided"] + assert strided.shape == ["0:n:"] assert strided.storage.array.contiguous is False assert computed.shape == ["xl.size"] assert bounded.constraints == [ diff --git a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py index fdb87ca9e..4d66a794b 100644 --- a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py +++ b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py @@ -242,7 +242,7 @@ def apply( assert args["A"].source_shape == ["LDA", "N"] assert args["A"].lower_bounds == [None, None] assert args["A"].upper_bounds == [None, None] - assert args["work"].shape == ["::Strided"] + assert args["work"].shape == ["::"] assert args["work"].axes == ["strided"] assert args["work"].contiguous is False assert args["work"].source_shape == [] @@ -263,7 +263,7 @@ def test_convert_pyi_to_ir_reads_a_strided_axis_from_its_empty_step(): ) arrays = [variable.semantic_type.storage.array for variable in module.variables] - assert [array.shape for array in arrays] == [["::Strided"], ["0:n:Strided"]] + assert [array.shape for array in arrays] == [["::"], ["0:n:"]] assert [array.axes for array in arrays] == [["strided"], ["strided"]] assert [array.contiguous for array in arrays] == [False, False] diff --git a/tests/fortran/strings/semantics/test_string_pyi_semantics.py b/tests/fortran/strings/semantics/test_string_pyi_semantics.py index 8de38d5c5..7f9ddb587 100644 --- a/tests/fortran/strings/semantics/test_string_pyi_semantics.py +++ b/tests/fortran/strings/semantics/test_string_pyi_semantics.py @@ -79,7 +79,7 @@ def array_assumed_strided(values: String[...][::]) -> None: ... assert assumed_type.metadata["fortran_character_length"] == "*" assert assumed_type.rank == 1 assert assumed_type.shape == [":"] - assert array_assumed_strided.arguments[0].semantic_type.shape == ["::Strided"] + assert array_assumed_strided.arguments[0].semantic_type.shape == ["::"] emitted = emit_module(module) assert "value: String" in emitted From 0b9d450a02170179e34b57a420f80b27cdee87a5 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 04:40:35 +0100 Subject: [PATCH 10/24] Record the declaring name for a renamed re-export chain A reference reached through renaming re-exports followed the module provenance back to the declaration but kept the alias it was last bound to, so the metadata claimed the declaring module defines a name it never does: name MID against origin_module A, where A declares OBJ. The declaration names the symbol, so _bind_prototype_reference takes it from the resolved prototype instead of from a caller that may only hold an intermediate alias. The one caller that already passed the declaring name is unaffected, and the caller that could not know it no longer has to. A rename and a same-name re-export were each covered; their combination was not, which is where this sat. Both routes are now covered: the contract chain through reconciliation, and a Fortran chain generated to contracts, built and called. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 ++ prik/semantics/pyi2ir.py | 12 ++- .../test_multi_file_contract_generation.py | 83 +++++++++++++++++++ .../semantics/test_pyi_callback_semantics.py | 30 +++++++ 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a885f5e..1cfddd8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- A callback interface reached through renaming re-exports now records the name + its declaring module gives it. The reference followed the module back to the + declaration but kept an alias from partway along the chain, so it named a + symbol that module does not define. + - The semantic IR now carries a strided axis as `::`, the spelling a contract uses, instead of a longer internal token. `prik semantics` output changes accordingly; contracts, docstrings and generated sources are unaffected diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 3f617fa62..8fa95e24c 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -254,7 +254,6 @@ def _resolve_local_prototype_references(self) -> None: semantic_type, prototype, origin_module=self.module.name, - source_name=prototype.name, ) def _resolve_declaration_expression_callables(self) -> None: @@ -3870,11 +3869,17 @@ def _bind_prototype_reference( prototype: SemanticPrototype, *, origin_module: str, - source_name: str, declared_types: frozenset[str] = frozenset(), ) -> None: - """Complete one type annotation as a named callback prototype reference.""" + """Complete one type annotation as a named callback prototype reference. + + The declaring prototype names the symbol. A reference reached through + renaming re-exports carries the last alias it passed through, which names + nothing in the module that declares it, so the name is taken from the + declaration rather than from the caller. + """ local_name = semantic_type.name + source_name = prototype.name arguments = deepcopy(prototype.arguments) return_type = deepcopy(prototype.return_type) or SemanticType("None", dtype="None") # The prototype's own types are written in the declaring module's scope, so @@ -3981,7 +3986,6 @@ def _bind_referenced_prototype( semantic_type, prototype, origin_module=declaring_module or origin_module.lstrip("."), - source_name=source_name, declared_types=declared_class_names.get(declaring_module, frozenset()), ) return True diff --git a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py index 92d645566..d0168d026 100644 --- a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py +++ b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py @@ -249,3 +249,86 @@ def test_imported_callback_returning_a_module_owned_type_builds(tmp_path: Path): output_name="callback_result_types", ) assert result.shared_library.exists() + + +RENAMED_CHAIN_SOURCE = """ +module chain_declares_mod + implicit none + abstract interface + subroutine OBJ(x, f) + implicit none + real(8), intent(in) :: x + real(8), intent(out) :: f + end subroutine OBJ + end interface +end module chain_declares_mod + +module chain_middle_mod + use, non_intrinsic :: chain_declares_mod, only : MID => OBJ + implicit none + public :: MID +end module chain_middle_mod + +module chain_consumer_mod + use, non_intrinsic :: chain_middle_mod, only : LOCAL => MID + implicit none +contains + subroutine run_chain(calfun, x, f) + procedure(LOCAL) :: calfun + real(8), intent(in) :: x + real(8), intent(out) :: f + + call calfun(x, f) + end subroutine run_chain +end module chain_consumer_mod +""" + + +def test_renamed_reexport_chain_builds_through_its_generated_contracts(tmp_path: Path): + """Each hop renames the interface, so only the declaring module names it. + + A rename and a re-export are covered separately elsewhere; combining them + is what exposes a reference that followed the module back to the declaration + while keeping an alias from somewhere along the way. + """ + source = tmp_path / "chain.f90" + source.write_text(RENAMED_CHAIN_SOURCE, encoding="utf-8") + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + str(source), + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + + # Each contract mirrors the `use` its own module wrote. + assert "from chain_declares_mod import OBJ as MID" in (contracts / "chain_middle_mod.pyi").read_text( + encoding="utf-8" + ) + consuming = (contracts / "chain_consumer_mod.pyi").read_text(encoding="utf-8") + assert "from chain_middle_mod import MID as LOCAL" in consuming + assert "calfun: LOCAL" in consuming + + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(source)], + output_dir=tmp_path / "build", + output_name="renamed_chain_callbacks", + ) + module = _import_from_build_dir(result.module_name, result.output_dir) + + def objective(x, f): + f[...] = float(x) * 7.0 + + assert module.chain_consumer_mod.run_chain(objective, np.float64(6.0)) == np.float64(42.0) diff --git a/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py b/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py index a32446cc8..4da816d3e 100644 --- a/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py @@ -1,7 +1,9 @@ """Tests split by stable ownership concept from `test_python_ast_contracts.py`.""" import pytest +from prik.pipeline.pyi import pyi_paths_to_semantic_modules from prik.policy.completion import complete_semantic_policies +from prik.semantics.models import PROTOTYPE_REF_METADATA from tests.fortran._support.pyi_conversion import parse_pyi_text @@ -215,3 +217,31 @@ def test_convert_pyi_to_ir_rejects_redundant_or_invalid_prototype_value_wrappers f"@prototype\ndef callback(value: {annotation}) -> None: ...", module_name="callbacks", ) + + +def test_renamed_reexport_chain_resolves_to_the_declaring_name(tmp_path): + """A reference follows both module and symbol provenance to the declaration. + + Each hop of a renaming chain binds a new alias, and only the module that + declares the prototype knows the name it declared. Recording an alias from + somewhere along the chain would name a symbol the declaring module does not + define. + """ + for name, text in ( + ( + "mod_a.pyi", + "from prik.contracts import Float64, In, prototype\n\n@prototype\ndef OBJ(x: In(Float64)) -> None: ...\n", + ), + ("mod_b.pyi", "from mod_a import OBJ as MID\n"), + ("mod_c.pyi", "from mod_b import MID as LOCAL\n\ndef run(callback: LOCAL) -> None: ...\n"), + ): + (tmp_path / name).write_text(text, encoding="utf-8") + + modules = {module.name: module for module in pyi_paths_to_semantic_modules(sorted(tmp_path.glob("*.pyi")))} + + callback = next(item for item in modules["mod_c"].functions if item.name == "run").arguments[0].semantic_type + assert callback.metadata[PROTOTYPE_REF_METADATA] == { + "name": "OBJ", + "local_name": "LOCAL", + "origin_module": "mod_a", + } From f0b3c1446b644dd05d0bc686119f402d9e053157 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 07:09:52 +0100 Subject: [PATCH 11/24] Build one generic interface from every block that declares it Fortran lets a scope build a generic interface from several blocks, each contributing specifics. PRIMA does this under preprocessor guards, adding kind-specific procedures only for the precisions a build supports, so `huge_value` arrives as two blocks that gfortran accepts and the parser rejected as a duplicate declaration. Blocks naming one generic in one scope now merge into a single interface carrying every entry in declaration order, keyed by module so two modules in a file keep their own. Abstract and unnamed blocks are never generics and are untouched, and the duplicate check still holds for every other unit kind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 ++ prik/parsers/fortran/parser.py | 42 +++++++++-- .../parsing/test_generic_interface_syntax.py | 75 +++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfddd8c7..b02b773bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generic interface may now be declared across several blocks in one scope, + which Fortran allows and real sources use to add specifics under + preprocessor guards. The blocks become one generic carrying every entry in + declaration order, instead of being rejected as a duplicate declaration. + - A callback interface reached through renaming re-exports now records the name its declaring module gives it. The reference followed the module back to the declaration but kept an alias from partway along the chain, so it named a diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index c2aa379de..c1f3deb89 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -1938,10 +1938,12 @@ def _helper_attach_file_interfaces( units: _ParsedFileUnits, ) -> list[FortranInterface]: """Collect interfaces and attach module-owned blocks to their owners.""" - interfaces = [ - self._visit(unit, parent_scope=scope, filename=filename) - for unit, scope in self._collect_interface_source_units(lines, filename) - ] + interfaces = self._merged_generic_interfaces( + [ + self._visit(unit, parent_scope=scope, filename=filename) + for unit, scope in self._collect_interface_source_units(lines, filename) + ] + ) for module in units.modules: module.interfaces = [ iface for iface in interfaces if iface.module and iface.module.lower() == module.name.lower() @@ -1952,6 +1954,31 @@ def _helper_attach_file_interfaces( ] return [iface for iface in interfaces if iface.module is None] + @staticmethod + def _merged_generic_interfaces(interfaces: list[FortranInterface]) -> list[FortranInterface]: + """Combine blocks that extend one generic interface into a single record. + + Fortran lets a generic interface be built from several blocks in the + same scope, each contributing specifics. They name one generic, so the + parser reports one interface carrying every entry in declaration order. + Abstract and unnamed blocks are never generics and stay as they are. + """ + merged: dict[tuple[str, str], FortranInterface] = {} + result: list[FortranInterface] = [] + for interface in interfaces: + if not interface.name or interface.abstract: + result.append(interface) + continue + key = (str(interface.module or "").lower(), interface.name.lower()) + existing = merged.get(key) + if existing is None: + merged[key] = interface + result.append(interface) + continue + existing.procedures.extend(interface.procedures) + existing.specific_procedures.extend(interface.specific_procedures) + return result + def _resolve_file_compile_time_facts(self, units: _ParsedFileUnits) -> None: """Apply source-visible compile-time symbols within one parsed file. @@ -2811,7 +2838,12 @@ def _helper_validate_sibling_units( continue if unit.kind == "procedure": key = ("procedure", unit.name.lower()) - elif unit.kind in {"module", "submodule", "program", "block_data", "derived_type", "interface"}: + elif unit.kind == "interface": + # A generic interface may be declared in several blocks, each + # adding specifics to the same name, so a repeat is not a + # duplicate declaration. + continue + elif unit.kind in {"module", "submodule", "program", "block_data", "derived_type"}: key = (unit.kind, unit.name.lower()) else: continue diff --git a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py index a2b909b6a..f2cf65406 100644 --- a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py +++ b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py @@ -8,6 +8,7 @@ from tests.fortran._support.parser_procedures import ( parse_fortran_interfaces, parse_fortran_module, + parse_fortran_modules, ) from prik.parsers.fortran.models import FortranParseError @@ -105,3 +106,77 @@ def test_assumed_type_generic_candidate_is_rejected_at_parsing(): parse_fortran_file(source, filename="unsupported_generic.f90") assert exc_info.value.code == "PARSE_UNSUPPORTED_DECLARATION" + + +def test_generic_interface_declared_in_several_blocks_becomes_one_generic(): + """Fortran builds one generic from as many blocks as a scope declares. + + Real sources split a generic across preprocessor-guarded blocks, adding + specifics only for the kinds a build supports, so repeated blocks name one + generic rather than redeclaring it. + """ + source = """ +module huge_mod + implicit none + private + public :: huge_value + + interface huge_value + module procedure huge_value_sp, huge_value_dp + end interface huge_value + + interface huge_value + module procedure huge_value_qp + end interface huge_value +contains + real function huge_value_sp(x) + real, intent(in) :: x + huge_value_sp = huge(x) + end function huge_value_sp + real(8) function huge_value_dp(x) + real(8), intent(in) :: x + huge_value_dp = huge(x) + end function huge_value_dp + real(16) function huge_value_qp(x) + real(16), intent(in) :: x + huge_value_qp = huge(x) + end function huge_value_qp +end module huge_mod +""" + + module = parse_fortran_module(source) + + generics = [interface for interface in module.interfaces if interface.name] + assert len(generics) == 1 + assert generics[0].name == "huge_value" + assert generics[0].specific_procedures == ["huge_value_sp", "huge_value_dp", "huge_value_qp"] + + +def test_repeated_generic_names_stay_separate_per_module(): + """Two modules in one file each own their generic of the same name.""" + source = """ +module first_mod + implicit none + interface report + module procedure report_first + end interface report +contains + subroutine report_first() + end subroutine report_first +end module first_mod + +module second_mod + implicit none + interface report + module procedure report_second + end interface report +contains + subroutine report_second() + end subroutine report_second +end module second_mod +""" + + modules = {module.name: module for module in parse_fortran_modules(source)} + + assert [item.specific_procedures for item in modules["first_mod"].interfaces if item.name] == [["report_first"]] + assert [item.specific_procedures for item in modules["second_mod"].interfaces if item.name] == [["report_second"]] From ac314f3d751bb6d01e99248ea5ea9913581d3074 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 07:59:25 +0100 Subject: [PATCH 12/24] Extend a generic interface across the modules that build it A local interface block repeating a use-associated generic name extends that generic; it does not replace it. PRIK resolved only the specifics a module declared, so an extending module published a generic missing everything it inherited and rejected calls gfortran accepts. The importing scope now resolves the specifics that reached it through the import as well as its own, following the import chain. Accumulation stays one-directional, as Fortran requires: the declaring module gains nothing from a module that extends it later. An inherited specific joins the importing module privately, since the import bound the generic name and not the specific's own, so it is reachable only through the generic. Two identities had been inferred from an overload's first specific, which only holds while one module owns them all. A generic now records the scope that declares it, so an extended generic is published by the extending module rather than the one it inherited from, and a module generic addresses each candidate by the scope owning that procedure so an inherited one stays findable. Class-bound overloads are addressed by their class as before, which owns every candidate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++ prik/policy/construction.py | 27 ++++- prik/semantics/fortran2ir.py | 110 ++++++++++++++++-- prik/semantics/models.py | 2 + .../end_to_end/test_generic_interfaces.py | 61 ++++++++++ .../scope_name_reuse_combinations.json | 3 +- 6 files changed, 196 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b02b773bb..f8859cf17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generic interface that repeats a `use`-associated name now extends that + generic instead of replacing it, so the importing module dispatches to the + specifics it inherited as well as its own. Accumulation stays one-directional, + as Fortran requires: the declaring module does not gain what a later module + adds. An inherited specific is reachable only through the generic, because the + import never bound its own name. + - A generic interface may now be declared across several blocks in one scope, which Fortran allows and real sources use to add specifics under preprocessor guards. The blocks become one generic carrying every entry in diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 45737f052..54cece543 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -745,6 +745,22 @@ def _class_method_blockers(method: ClassMethodPolicy) -> str | None: return None +def _overload_candidate_scope( + procedure: models.SemanticFunction, + owner_path: str, + module_generic: bool, +) -> str: + """Return the scope that addresses one overload candidate. + + A module generic addresses each specific by the module that owns it, so a + specific inherited from an imported generic stays findable. A class-bound + overload is addressed by its class instead, which owns every candidate. + """ + if not module_generic: + return owner_path + return str(procedure.origin.native_scope or owner_path) + + def _overload_policy( owner_path: str, overload: models.ProcedureOverloadSet, @@ -752,13 +768,15 @@ def _overload_policy( python_name: str | None = None, procedures: tuple[models.SemanticFunction, ...] | None = None, python_exports: tuple[PythonExportPolicy, ...] = (), + module_generic: bool = False, ) -> OverloadPolicy: """Complete one overload set from explicit concrete-procedure links.""" selected = tuple(overload.procedures) if procedures is None else procedures public_name = python_name or overload.name candidates = tuple( OverloadCandidatePolicy( - owner_path=f"{owner_path}.{overload.name}.{procedure.name}", + owner_path=f"{_overload_candidate_scope(procedure, owner_path, module_generic)}" + f".{overload.name}.{procedure.name}", arguments=(), passed_object=False, ) @@ -782,13 +800,16 @@ def build_module_overload_policy( ) -> OverloadPolicy: """Complete the stable owner and Python exports for one module generic.""" if not overload.procedures: - return _overload_policy(module.name, overload) + return _overload_policy(overload.native_scope or module.name, overload, module_generic=True) first = overload.procedures[0] - native_scope = str(first.origin.native_scope or module.name) + # A generic extending an imported one holds specifics from another module, + # so the declared scope names the owner rather than the first specific. + native_scope = str(overload.native_scope or first.origin.native_scope or module.name) return _overload_policy( native_scope, overload, python_exports=completed_python_exports(first, overload.name), + module_generic=True, ) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index f9fa37031..63c9ec631 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -28,6 +28,7 @@ FortranEnum, FortranEnumerator, FortranFile, + FortranInterface, FortranModule, FortranProject, FortranProgram, @@ -1380,12 +1381,17 @@ def _visit_FortranModule( ), ) - overload_sets = self._module_overload_sets( + overload_sets, inherited_functions = self._module_overload_sets( module, procedure_lookup, context, semantic_classes, + module_index=index, ) + # A specific reached through a use-associated generic is callable here, + # so it joins this module's functions. The import never bound its own + # name, so it stays private and is reachable only through the generic. + semantic_functions.extend(inherited_functions) metadata = {} common_variables = {name.casefold() for name in module.common_variables} enum_constants = [ @@ -2533,7 +2539,9 @@ def _module_overload_sets( procedure_lookup: dict[str, SemanticFunction], context: _DerivedTypeContext, semantic_classes: list[SemanticClass], - ) -> list[ProcedureOverloadSet]: + *, + module_index: dict[str, FortranModule] | None = None, + ) -> tuple[list[ProcedureOverloadSet], list[SemanticFunction]]: """Convert module generic interfaces into function or class overload sets. Normal procedure generics remain module overloads. Defined operators @@ -2541,6 +2549,7 @@ def _module_overload_sets( constructors preserve the existing descriptive conversion failure. """ overload_sets: list[ProcedureOverloadSet] = [] + inherited_functions: list[SemanticFunction] = [] class_map = {semantic_class.name.casefold(): semantic_class for semantic_class in semantic_classes} for interface in module.interfaces: if not interface.name or interface.abstract: @@ -2553,10 +2562,19 @@ def _module_overload_sets( ) for signature in interface.procedures } - target_names = interface.specific_procedures or [signature.name for signature in interface.procedures] + inherited_names, inherited_lookup = self._inherited_generic_specifics( + module, + interface.name, + module_index or {}, + ) + for name in inherited_names: + if not any(item.name.casefold() == name.casefold() for item in inherited_functions): + inherited_functions.append(inherited_lookup[name.casefold()]) + own_names = interface.specific_procedures or [signature.name for signature in interface.procedures] + target_names = [*inherited_names, *own_names] procedures, missing = self._resolve_overload_targets( target_names, - procedure_lookup | inline_lookup, + procedure_lookup | inline_lookup | inherited_lookup, visibility=self._symbol_visibility(module, interface.name), ) if missing or not procedures: @@ -2570,7 +2588,7 @@ def _module_overload_sets( # constructor, so its specifics become the class's own # `__init__` overload set rather than a module generic. constructor_set = self._normal_overload_set("__init__", procedures) - target_lookup = procedure_lookup | inline_lookup + target_lookup = procedure_lookup | inline_lookup | inherited_lookup for target_name, candidate in zip(target_names, constructor_set.procedures, strict=True): if target_lookup[target_name.casefold()].visibility == "private": # A private specific is unreachable by name; the type @@ -2580,8 +2598,14 @@ def _module_overload_sets( self._merge_overload_sets(constructor_class.overload_sets, [constructor_set]) self._mark_constructor_specifics(procedures, procedure_lookup, interface.name) continue - overload_set = self._normal_overload_set(interface.name, procedures) - target_lookup = procedure_lookup | inline_lookup + overload_set = self._normal_overload_set( + interface.name, + procedures, + native_scope=str(module.origin.native_name or module.name) + if hasattr(module, "origin") + else module.name, + ) + target_lookup = procedure_lookup | inline_lookup | inherited_lookup for target_name, candidate in zip(target_names, overload_set.procedures, strict=True): if target_lookup[target_name.casefold()].visibility == "private": candidate.native_name = interface.name @@ -2596,7 +2620,7 @@ def _module_overload_sets( self._apply_assignment_projection_to_originals(interface.name, procedures, procedure_lookup, class_map) for semantic_class, class_sets in defined_sets: self._merge_overload_sets(semantic_class.overload_sets, class_sets) - return overload_sets + return overload_sets, inherited_functions def _bound_overload_sets( self, @@ -2701,7 +2725,12 @@ def _merge_overload_sets( existing.procedures.extend(overload_set.procedures) @staticmethod - def _normal_overload_set(name: str, procedures: list[SemanticFunction]) -> ProcedureOverloadSet: + def _normal_overload_set( + name: str, + procedures: list[SemanticFunction], + *, + native_scope: str | None = None, + ) -> ProcedureOverloadSet: """Copy regular generic candidates and attach generic dispatch metadata. Type-bound methods are projected back to ordinary functions while @@ -2733,7 +2762,7 @@ def _normal_overload_set(name: str, procedures: list[SemanticFunction]) -> Proce candidate.metadata[OVERLOAD_KIND_METADATA] = "generic" candidate.metadata[OVERLOAD_TARGET_METADATA] = candidate.native_name or candidate.name candidates.append(candidate) - return ProcedureOverloadSet(name, candidates) + return ProcedureOverloadSet(name, candidates, native_scope=native_scope) def _defined_overload_sets( self, @@ -2987,6 +3016,67 @@ def _is_procedure_generic_name(name: str) -> bool: """Return whether a generic spelling is an ordinary callable identifier.""" return re.fullmatch(r"[a-z_]\w*", name, re.IGNORECASE) is not None + def _inherited_generic_specifics( + self, + module: FortranModule, + generic_name: str, + modules: dict[str, FortranModule], + ) -> tuple[list[str], dict[str, SemanticFunction]]: + """Return the specifics one generic inherits from the generic it extends. + + A local interface block repeating a ``use``-associated generic name + extends that generic rather than replacing it, so this scope resolves + every specific that reached it through the import as well as its own. + Accumulation runs one way: the declaring module never sees what a later + module adds. + """ + source_module, source_generic = self._imported_generic_interface(module, generic_name, modules) + if source_module is None or source_generic is None: + return [], {} + inherited, lookup = self._inherited_generic_specifics(source_module, source_generic.name, modules) + signatures = {procedure.name.casefold(): procedure for procedure in source_module.procedures} + source_context = self._module_derived_type_context(source_module) + names = source_generic.specific_procedures or [item.name for item in source_generic.procedures] + for name in names: + signature = signatures.get(name.casefold()) + if signature is None or name.casefold() in lookup: + continue + function = self.visit(signature, visibility="private", derived_type_context=source_context) + lookup[name.casefold()] = function + inherited.append(name) + return inherited, lookup + + @staticmethod + def _imported_generic_interface( + module: FortranModule, + generic_name: str, + modules: dict[str, FortranModule], + ) -> tuple[FortranModule | None, FortranInterface | None]: + """Find the generic one module imports under ``generic_name``, if any.""" + for module_name, mappings in module.uses.items(): + source_module = modules.get(module_name.casefold()) + if source_module is None: + continue + sources = ( + [generic_name] + if not mappings + else [ + mapping.source for mapping in mappings if mapping.local_name.casefold() == generic_name.casefold() + ] + ) + for source_name in sources: + generic = next( + ( + item + for item in source_module.interfaces + if item.name and not item.abstract and item.name.casefold() == source_name.casefold() + ), + None, + ) + if generic is not None: + return source_module, generic + return None, None + @staticmethod def _resolve_overload_targets( target_names: list[str], diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 063cef5ae..ffe89c012 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -391,6 +391,8 @@ class SemanticMethod(SemanticFunction): class ProcedureOverloadSet: name: str procedures: list[SemanticFunction] = field(default_factory=list) + native_scope: str | None = None + """Module declaring the generic, which need not own every specific.""" FORTRAN_GENERIC_NAME_METADATA = "fortran_generic_name" diff --git a/tests/fortran/generic_interfaces/end_to_end/test_generic_interfaces.py b/tests/fortran/generic_interfaces/end_to_end/test_generic_interfaces.py index de7d6ad26..79d17fe79 100644 --- a/tests/fortran/generic_interfaces/end_to_end/test_generic_interfaces.py +++ b/tests/fortran/generic_interfaces/end_to_end/test_generic_interfaces.py @@ -6,6 +6,7 @@ import pytest from tests.fortran._support.wrapper_build import ( + _build_source_and_import, _build_source_or_generated_pyi_and_import, _build_sources_and_import, ) @@ -123,3 +124,63 @@ def test_public_generic_dispatches_to_private_inline_submodule_specifics(tmp_pat assert "native__prik_overload_shift_1 => shift" in bridge assert "=> shift_integer" not in bridge assert "=> shift_real" not in bridge + + +EXTENDED_GENERIC_SOURCE = """ +module gen_base_mod + implicit none + interface report + module procedure report_int + end interface report +contains + subroutine report_int(value, seen) + integer, intent(in) :: value + integer, intent(out) :: seen + seen = value + end subroutine report_int +end module gen_base_mod + +module gen_extended_mod + use gen_base_mod, only : report + implicit none + interface report + module procedure report_real + end interface report +contains + subroutine report_real(value, seen) + real(8), intent(in) :: value + integer, intent(out) :: seen + seen = int(value) * 10 + end subroutine report_real +end module gen_extended_mod +""" + + +def test_generic_extended_across_modules_dispatches_to_every_specific(tmp_path: Path): + """A local interface block extends the generic it imports, not replaces it. + + The extending module resolves both the specific it declares and the one + that reached it through the import, while the declaring module keeps only + its own: a generic accumulates along the `use` chain in one direction. + """ + source = tmp_path / "gen_extended.f90" + source.write_text(EXTENDED_GENERIC_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + { + "bind_c_gen_extended_wrapper.f90", + "gen_extended_wrapper.c", + "gen_extended_wrapper.h", + }, + ) + + assert module.gen_extended_mod.report(np.int32(3)) == np.int32(3) + assert module.gen_extended_mod.report(np.float64(4.0)) == np.int32(40) + assert module.gen_base_mod.report(np.int32(3)) == np.int32(3) + + # The inherited specific is reachable only through the generic, because + # `use gen_base_mod, only : report` never bound its own name. + assert "report_int" not in dir(module.gen_extended_mod) + with pytest.raises(TypeError, match="no matching overload"): + module.gen_base_mod.report(np.float64(4.0)) diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json index 6dcbbac10..a2459ada2 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json @@ -1351,7 +1351,8 @@ "metadata": {} } } - ] + ], + "native_scope": "scope_name_reuse_combinations" } ], "classes": [ From dfd955f4d7ee6ef43508a61616800613bdb0f892 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 09:57:16 +0100 Subject: [PATCH 13/24] Publish an imported procedure a module explicitly makes public A module that names an imported procedure in a `public` statement means it to be part of its own interface, but PRIK dropped the module entirely: a facade that only re-exports reached Python as nothing at all, so callers had to reach past it into the modules it was hiding. The name is published without repeating the declaration. A re-export names an existing wrapper rather than adding one, so the plan carries an alias binding the name to the callable its declaring namespace already exposes. One wrapper is generated, the contract keeps spelling the re-export as the import it already was, and `facade.proc is home.proc` holds. Naming the entity is what states the intent. A name public only because the module default is public carries no such statement, and mirroring that would republish everything a module happens to import under every namespace that imports it, so those are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 +++ prik/codegen/c/binding.py | 31 ++++++++++ prik/pipeline/build.py | 1 + prik/planning/models.py | 14 +++++ prik/planning/planner.py | 37 +++++++++++- prik/runtime/native_support/prik_binding.h | 16 +++++ prik/semantics/fortran2ir.py | 60 ++++++++++++++++--- prik/semantics/models.py | 17 ++++++ .../general/expected/basic_subroutine.json | 1 + .../expected/compile_time_all_exprs.json | 1 + .../expected/compile_time_shape_exprs.json | 1 + .../general/expected/derived_type.json | 1 + .../expected/derived_types_and_methods.json | 1 + .../general/expected/modern_pyi_example.json | 1 + .../general/expected/module_vars_use.json | 1 + .../expected/procedures_and_functions.json | 1 + .../scope_name_reuse_combinations.json | 1 + .../test_module_variables_and_state.py | 52 ++++++++++++++++ 18 files changed, 234 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8859cf17..d6352b18b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A module that names an imported procedure in a `public` statement now + publishes it, so a facade module reaches Python instead of disappearing. The + declaration is not repeated: the published name binds to the one wrapper its + declaring module exposes, so `facade.proc is home.proc`, and the contract + keeps spelling the re-export as the import it already was. A name public only + because the module default is public states no such intent and is unchanged. + - A generic interface that repeats a `use`-associated name now extends that generic instead of replacing it, so the importing module dispatches to the specifics it inherited as well as its own. Accumulation stays one-directional, diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index b86c7febf..875200e66 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -15340,10 +15340,41 @@ def _module_init( for namespace in child_namespaces for node in self._child_namespace_import_registration_nodes(plan, namespace) ), + # Aliases bind after every namespace is populated, so the + # callable a re-export names already exists. + *( + node + for namespace in (root_namespace, *child_namespaces) + for node in self._namespace_alias_nodes(plan, namespace) + ), CReturn(CodeExpression("mod")), ), ) + def _namespace_alias_nodes( + self, + plan: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[CExpressionStatement, ...]: + """Bind each re-exported name to the callable its owner already exposes. + + A re-export publishes an existing declaration, so the name is bound to + that one object rather than to a second wrapper for the same procedure. + """ + target = self._namespace_object_name(namespace) + nodes: list[CExpressionStatement] = [] + for alias in namespace.aliases: + source = self._namespace_object_name(self._namespace(plan, alias.source_namespace)) + nodes.append( + CExpressionStatement( + CodeExpression( + f'if (prik_bind_namespace_alias({target}, "{alias.python_name}", ' + f'{source}, "{alias.source_name}") < 0) {{ Py_DECREF(mod); return NULL; }}' + ) + ) + ) + return tuple(nodes) + def _ordered_child_namespaces(self, plan: ModulePlan) -> tuple[NamespacePlan, ...]: """Return parents before descendants regardless of editable tuple order.""" return tuple( diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index edc3ac4e5..3f845d34f 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -3057,6 +3057,7 @@ def _merge_wrapper_modules(modules: list[SemanticModule], *, name: str | None = functions=[function for module in modules for function in module.functions], prototypes=[prototype for module in modules for prototype in module.prototypes], overload_sets=[overload for module in modules for overload in module.overload_sets], + reexports=[reexport for module in modules for reexport in module.reexports], classes=[semantic_class for module in modules for semantic_class in module.classes], variables=[variable for module in modules for variable in module.variables], metadata=_wrapper_module_metadata(modules), diff --git a/prik/planning/models.py b/prik/planning/models.py index dbce5c7c0..d3a2d1db2 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -1387,6 +1387,19 @@ class DeclarationCallablePlan(StageRecord): prototype: ProcedurePrototypePlan | None = None +@dataclass +class NamespaceAliasPlan(StageRecord): + """Bind one name in a namespace to a callable another namespace owns. + + A re-export publishes an existing declaration rather than adding one, so + the alias names where the callable lives instead of repeating its plan. + """ + + python_name: str + source_namespace: tuple[str, ...] + source_name: str + + @dataclass class NamespacePlan(StageRecord): """Represent one Python namespace and its directly exported wrapper owners. @@ -1403,6 +1416,7 @@ class NamespacePlan(StageRecord): derived_types: tuple[DerivedTypePlan, ...] = () classes: tuple[ClassSurfacePlan, ...] = () overloads: tuple[OverloadPlan, ...] = () + aliases: tuple[NamespaceAliasPlan, ...] = () docstring: str | None = None diff --git a/prik/planning/planner.py b/prik/planning/planner.py index ab41dac7a..7ca7918b8 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -130,6 +130,7 @@ NativeEntrypointParameterPlan, NativeEntrypointProjectedSlotPlan, NativeEntrypointResultPlan, + NamespaceAliasPlan, NamespacePlan, NativeArrayActualPlan, NativeArrayDefaultHandlePlan, @@ -371,8 +372,16 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: module, class_policies, ) + aliases = self._aliases_by_namespace(module) if not any( - (*functions.values(), *variables.values(), *derived_types.values(), *classes.values(), *overloads.values()) + ( + *functions.values(), + *variables.values(), + *derived_types.values(), + *classes.values(), + *overloads.values(), + *aliases.values(), + ) ): raise ValueError(f"Semantic module {module.name!r} has no public wrapper exports") @@ -381,7 +390,9 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: self._attach_overload_functions(functions, overloads) # Complete stable namespace paths, generated symbols, and required headers. - namespaces = self._namespace_plans(module.name, functions, variables, derived_types, classes, overloads) + namespaces = self._namespace_plans( + module.name, functions, variables, derived_types, classes, overloads, aliases + ) support_projection = build_generated_support_procedure_projection(namespaces) support_procedures = support_projection.support_procedures generated_code_groups = self._native_generated_code_groups( @@ -509,10 +520,13 @@ def _namespace_plans( derived_types: dict, classes: dict, overloads: dict, + aliases: dict, ) -> tuple[NamespacePlan, ...]: """Freeze linked namespace members in dependency-safe path order.""" self._complete_generated_symbols(functions, variables) - namespace_paths = self._namespace_paths((*functions, *variables, *derived_types, *classes, *overloads)) + namespace_paths = self._namespace_paths( + (*functions, *variables, *derived_types, *classes, *overloads, *aliases) + ) return tuple( self._namespace_plan( module_name, @@ -522,10 +536,25 @@ def _namespace_plans( tuple(derived_types[path]), tuple(classes[path]), tuple(overloads[path]), + tuple(aliases[path]), ) for path in namespace_paths ) + @staticmethod + def _aliases_by_namespace(module: models.SemanticModule) -> dict[tuple[str, ...], list[NamespaceAliasPlan]]: + """Group each published re-export under the namespace that publishes it.""" + grouped = defaultdict(list) + for reexport in module.reexports: + grouped[(reexport.module.casefold(),)].append( + NamespaceAliasPlan( + python_name=reexport.local_name, + source_namespace=(reexport.origin_module.casefold(),), + source_name=reexport.source_name, + ) + ) + return grouped + def _namespace_plan( self, module_name: str, @@ -535,6 +564,7 @@ def _namespace_plan( derived_types: tuple[DerivedTypePlan, ...], classes: tuple[ClassSurfacePlan, ...], overloads: tuple[OverloadPlan, ...], + aliases: tuple[NamespaceAliasPlan, ...] = (), ) -> NamespacePlan: """Create one namespace after its generated symbols are complete.""" return NamespacePlan( @@ -545,6 +575,7 @@ def _namespace_plan( derived_types=derived_types, classes=classes, overloads=overloads, + aliases=aliases, ) def _complete_derived_backend_symbols( diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index eea14001f..00c820106 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -1406,6 +1406,22 @@ static inline PyObject *prik_float32_to_numpy(const float *value) return result; } +/* Bind one name in a namespace to a callable another namespace owns, so a + * re-exported procedure resolves to the single wrapper that defines it. */ +static inline int prik_bind_namespace_alias(PyObject *target, const char *name, PyObject *source, + const char *source_name) +{ + PyObject *value = PyObject_GetAttrString(source, source_name); + int status; + + if (value == NULL) { + return -1; + } + status = PyObject_SetAttrString(target, name, value); + Py_DECREF(value); + return status; +} + static inline PyObject *prik_float64_to_numpy(const double *value) { PyObject *result = PyArrayScalar_New(Double); diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 63c9ec631..cac206ddb 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -89,6 +89,7 @@ SemanticModule, SemanticOrigin, SemanticPrototype, + SemanticReexport, SemanticStorageContract, SemanticType, SemanticVariable, @@ -289,6 +290,10 @@ def replace_symbol(match: re.Match[str]) -> str: return re.sub(r"\b[A-Za-z_][A-Za-z0-9_]*\b", replace_symbol, raw) +# Language-owned modules are contract vocabulary, not sibling contract leaves. +_INTRINSIC_FORTRAN_MODULES = frozenset({"iso_c_binding", "iso_fortran_env"}) + + class FortranToIRConverter(ClassVisitor): """Convert parsed Fortran models into semantic IR models. @@ -1431,6 +1436,7 @@ def _visit_FortranModule( classes=semantic_classes, variables=module_variables + enum_constants, imports=self._module_imports(module), + reexports=self._module_reexports(module), metadata=metadata, origin=SemanticOrigin( source_language="fortran", @@ -1527,6 +1533,30 @@ def procedures_to_semantic_module( ), ) + @staticmethod + def _module_reexports(module: FortranModule) -> list[SemanticReexport]: + """Return the imported names this module explicitly publishes. + + Naming an imported entity in a ``public`` statement says the module + means it to be part of its own interface, so that name is published + here as well. A name that is public only because the module default is + public carries no such statement and stays where it was declared. + """ + declared = { + *(procedure.name.casefold() for procedure in module.procedures), + *(derived.name.casefold() for derived in module.derived_types), + *(variable.name.casefold() for variable in getattr(module, "variables", ())), + } + published = {str(name).casefold() for name in getattr(module, "public_symbols", ())} + reexports: list[SemanticReexport] = [] + for module_name, mappings in module.uses.items(): + for mapping in mappings: + local_name = mapping.local_name + if local_name.casefold() in declared or local_name.casefold() not in published: + continue + reexports.append(SemanticReexport(local_name, module_name, mapping.source, module.name)) + return reexports + @staticmethod def _module_imports(module: FortranModule) -> list[str | SemanticImport]: """Translate parser ``use`` mappings while preserving parser declaration order.""" @@ -2562,16 +2592,12 @@ def _module_overload_sets( ) for signature in interface.procedures } - inherited_names, inherited_lookup = self._inherited_generic_specifics( + target_names, inherited_lookup = self._generic_target_names( module, - interface.name, + interface, module_index or {}, + inherited_functions, ) - for name in inherited_names: - if not any(item.name.casefold() == name.casefold() for item in inherited_functions): - inherited_functions.append(inherited_lookup[name.casefold()]) - own_names = interface.specific_procedures or [signature.name for signature in interface.procedures] - target_names = [*inherited_names, *own_names] procedures, missing = self._resolve_overload_targets( target_names, procedure_lookup | inline_lookup | inherited_lookup, @@ -3016,6 +3042,26 @@ def _is_procedure_generic_name(name: str) -> bool: """Return whether a generic spelling is an ordinary callable identifier.""" return re.fullmatch(r"[a-z_]\w*", name, re.IGNORECASE) is not None + def _generic_target_names( + self, + module: FortranModule, + interface: FortranInterface, + modules: dict[str, FortranModule], + inherited_functions: list[SemanticFunction], + ) -> tuple[list[str], dict[str, SemanticFunction]]: + """Order one generic's specifics, inherited before locally declared. + + ``inherited_functions`` collects each specific this module gained from + the generic it extends, so the module can carry them for dispatch. + """ + inherited_names, inherited_lookup = self._inherited_generic_specifics(module, interface.name, modules) + known = {item.name.casefold() for item in inherited_functions} + inherited_functions.extend( + inherited_lookup[name.casefold()] for name in inherited_names if name.casefold() not in known + ) + own_names = interface.specific_procedures or [signature.name for signature in interface.procedures] + return [*inherited_names, *own_names], inherited_lookup + def _inherited_generic_specifics( self, module: FortranModule, diff --git a/prik/semantics/models.py b/prik/semantics/models.py index ffe89c012..48c1c4ae5 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -672,12 +672,29 @@ class SemanticImport: items: list[SemanticImportItem] = field(default_factory=list) +@dataclass +class SemanticReexport: + """Record one name a module publishes on behalf of the module it imports. + + A re-export names an existing declaration rather than adding one, so it + carries only where the declaration lives and what this module calls it. + """ + + local_name: str + origin_module: str + source_name: str + module: str = "" + """Module publishing the name, which is not the one declaring it.""" + + @dataclass class SemanticModule: name: str functions: list[SemanticFunction] = field(default_factory=list) + reexports: list[SemanticReexport] = field(default_factory=list) + prototypes: list[SemanticPrototype] = field(default_factory=list) overload_sets: list[ProcedureOverloadSet] = field(default_factory=list) diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/basic_subroutine.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/basic_subroutine.json index 14299dc23..57e8c192f 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/basic_subroutine.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/basic_subroutine.json @@ -234,6 +234,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_all_exprs.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_all_exprs.json index 6cc74d754..cd59851b8 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_all_exprs.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_all_exprs.json @@ -1128,6 +1128,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_shape_exprs.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_shape_exprs.json index 468c95111..20f253b50 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_shape_exprs.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/compile_time_shape_exprs.json @@ -274,6 +274,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_type.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_type.json index f87a1fca7..af9bcfa97 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_type.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_type.json @@ -114,6 +114,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [ diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_types_and_methods.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_types_and_methods.json index afd9fb1a0..27fab4266 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_types_and_methods.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/derived_types_and_methods.json @@ -3,6 +3,7 @@ { "name": "mesh_mod", "functions": [], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [ diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json index 515aec6c5..876c6f9f9 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/modern_pyi_example.json @@ -1852,6 +1852,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [ diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/module_vars_use.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/module_vars_use.json index 22dbd792a..716355eb7 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/module_vars_use.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/module_vars_use.json @@ -3,6 +3,7 @@ { "name": "constants_mod", "functions": [], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json index 4ea1363d2..acaf83ecd 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/procedures_and_functions.json @@ -414,6 +414,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [], "classes": [], diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json index a2459ada2..904d7b475 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json +++ b/tests/fortran/infrastructure/semantic_ir/semantics/fixtures/general/expected/scope_name_reuse_combinations.json @@ -1002,6 +1002,7 @@ } } ], + "reexports": [], "prototypes": [], "overload_sets": [ { diff --git a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py index 04268fe8c..5e4b627fb 100644 --- a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py +++ b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py @@ -7,6 +7,7 @@ import numpy as np import pytest from tests.fortran._support.wrapper_build import ( + _build_source_and_import, _build_source_or_generated_pyi_and_import, _build_text_and_import, _sole_native_module, @@ -534,3 +535,54 @@ def test_declared_length_character_module_arrays_compile_and_expose_their_width( module.deferred_ptr.deallocate() assert module.deferred_ptr.associated is False assert module.deferred_ptr.shape is None + + +REEXPORT_SOURCE = """ +module reexport_home_mod + implicit none +contains + subroutine scale_value(value, scaled) + integer, intent(in) :: value + integer, intent(out) :: scaled + scaled = value * 2 + end subroutine scale_value +end module reexport_home_mod + +module reexport_facade_mod + use reexport_home_mod, only : scale_value + implicit none + private + public :: scale_value +end module reexport_facade_mod + +module reexport_default_mod + use reexport_home_mod + implicit none +end module reexport_default_mod +""" + + +def test_explicitly_published_import_is_reachable_without_a_second_wrapper(tmp_path: Path): + """Naming an imported procedure in a `public` statement publishes it here. + + The declaration is not repeated: the published name binds to the one + wrapper its own module exposes, so both namespaces share a single callable. + A module that merely imports without publishing adds no name of its own. + """ + source = tmp_path / "reexport.f90" + source.write_text(REEXPORT_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + {"bind_c_reexport_wrapper.f90", "reexport_wrapper.c", "reexport_wrapper.h"}, + ) + + assert module.reexport_facade_mod.scale_value is module.reexport_home_mod.scale_value + assert module.reexport_facade_mod.scale_value(np.int32(4)) == np.int32(8) + + # A plain `use` states no intent to publish, so it adds nothing. + assert not hasattr(module, "reexport_default_mod") or "scale_value" not in dir(module.reexport_default_mod) + + # One wrapper defines the procedure; the facade only names it again. + generated = (tmp_path / "build" / "reexport_wrapper.c").read_text(encoding="utf-8") + assert generated.count("static PyObject * wrap_scale_value") == 1 From 841575d2f3f166fdf525f6b0098fa85d9da58d17 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 10:16:03 +0100 Subject: [PATCH 14/24] codex: generate relative sibling imports in Fortran leaf contracts --- CHANGELOG.md | 5 ++++ docs/user/reference/pyi-format.md | 14 ++++++++--- prik/pipeline/build.py | 18 +++++++++++++ prik/planning/entrypoints.py | 13 ++++++++-- prik/planning/planner.py | 4 +-- prik/printers/pyi.py | 8 +++--- prik/semantics/pyi2ir.py | 2 +- .../combined_modules/box_ops.pyi | 2 +- .../combined_modules/second_math.pyi | 2 +- .../end_to_end/test_multi_source_builds.py | 25 ++++++++++++++++++- .../test_pyi_printer_imports_and_packages.py | 15 ++++++++--- 11 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6352b18b..1b1e95ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- Generated Fortran module leaves now import sibling contracts relatively, so + building a leaf directly loads the contracts its declarations depend on. + A native derived type exported through several modules shares one set of + generated support procedures. + - A module that names an imported procedure in a `public` statement now publishes it, so a facade module reaches Python instead of disappearing. The declaration is not repeated: the published name binds to the one wrapper its diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 9e47748fb..3eb62da4b 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -114,8 +114,8 @@ The generated forms therefore have these responsibilities: | C `.pyi` | Selected C declarations in one directly buildable contract file. | A contract build receives one entry `.pyi`: the package `__init__.pyi` for the -Fortran layout above, or the C file itself. Relative imports from a package -entry discover its leaf files. +full Fortran package, a Fortran module leaf for that module and its imported +siblings, or the C file itself. Relative imports discover dependent contracts. ### Entry Contract And Extension Identity @@ -141,13 +141,21 @@ contracts/ Building `api.pyi` directly exposes its declarations at the extension root and uses `api` as the default extension name. -Use the entry, not every imported leaf, on the command line: +Use one entry on the command line to build the full package: ```bash python3 -m prik contracts/solver/__init__.pyi \ --native-objects build/solver.o ``` +To build a module leaf directly, pass that leaf as the entry. Its relative +imports load sibling contracts needed by its declarations: + +```bash +python3 -m prik contracts/solver/solver_mod.pyi \ + --native-objects build/solver.o +``` + A source-free C contract also needs its native language selected explicitly: ```bash diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 3f845d34f..aaa56bb5b 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -66,6 +66,7 @@ SemanticImport, SemanticModule, SemanticPrototype, + SemanticReexport, SemanticVariable, _module_semantic_types, ) @@ -2063,6 +2064,23 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM tree = _pyi_export_tree(entry, modules_by_path, cache={}, pending=set()) _record_pyi_exports(tree) + for module in modules_by_path.values(): + for declaration in module.classes: + exports = _declaration_exports(declaration) + if len(exports) < 2: + continue + primary = exports[0] + source_namespace = ".".join(primary["namespace"]) + for alias in exports[1:]: + module.reexports.append( + SemanticReexport( + local_name=alias["name"], + origin_module=source_namespace, + source_name=primary["name"], + module=".".join(alias["namespace"]), + ) + ) + exports[:] = [primary] def _pyi_export_tree( diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 9a46916a8..527fa4ebf 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -133,8 +133,17 @@ def __init__(self, namespaces: tuple[NamespacePlan, ...]) -> None: self.namespaces = namespaces self.functions = tuple(function for namespace in namespaces for function in namespace.functions) self.variables = tuple(variable for namespace in namespaces for variable in namespace.variables) - self.derived_types = tuple(derived for namespace in namespaces for derived in namespace.derived_types) - self.classes = tuple(surface for namespace in namespaces for surface in namespace.classes) + # One native type may be exported through several Python namespaces. + # Its support procedures belong to the native type, not each export. + derived_by_identity = {} + classes_by_identity = {} + for namespace in namespaces: + for derived in namespace.derived_types: + derived_by_identity.setdefault(derived.type_identity, derived) + for surface in namespace.classes: + classes_by_identity.setdefault(surface.type_identity, surface) + self.derived_types = tuple(derived_by_identity.values()) + self.classes = tuple(classes_by_identity.values()) def build(self) -> GeneratedSupportProcedureProjection: """Collect external and binding-local support in declaration order.""" diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 7ca7918b8..6d24b68be 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -546,10 +546,10 @@ def _aliases_by_namespace(module: models.SemanticModule) -> dict[tuple[str, ...] """Group each published re-export under the namespace that publishes it.""" grouped = defaultdict(list) for reexport in module.reexports: - grouped[(reexport.module.casefold(),)].append( + grouped[tuple(part.casefold() for part in reexport.module.split(".") if part)].append( NamespaceAliasPlan( python_name=reexport.local_name, - source_namespace=(reexport.origin_module.casefold(),), + source_namespace=tuple(part.casefold() for part in reexport.origin_module.split(".") if part), source_name=reexport.source_name, ) ) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index e83a7756f..3ba5c80a6 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -74,6 +74,7 @@ _module_semantic_types, ) from prik.semantics.native_array_handles import native_array_data_type, native_array_descriptor_kind +from prik.semantics.pyi_metadata import PYI_LOADED_METADATA from prik.utilities.visitor import ClassVisitor _WRAPPED_CALLABLE_TYPE_METADATA = "pyi_wrapped_callable_type" @@ -1445,7 +1446,7 @@ def _append_imports( sections.append(contract_import) imports = self._effective_imports(module) for imp in imports: - sections.append(self._emit_import(imp)) + sections.append(self._emit_import(imp, native_source=not module.metadata.get(PYI_LOADED_METADATA))) if contract_import or imports: sections.append("") @@ -1736,14 +1737,15 @@ def class_has_overloads(cls: SemanticClass) -> bool: ) @staticmethod - def _emit_import(imp: str | SemanticImport) -> str: + def _emit_import(imp: str | SemanticImport, *, native_source: bool = False) -> str: """Emit import syntax.""" if isinstance(imp, str): return f"import {imp}" if not imp.items: return f"import {imp.module}" items = ", ".join(PyiPrinter._emit_import_item(item) for item in imp.items) - return f"from {imp.module} import {items}" + module_name = f".{imp.module}" if native_source and not imp.module.startswith(".") else imp.module + return f"from {module_name} import {items}" @staticmethod def _emit_import_item(item: SemanticImportItem) -> str: diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 8fa95e24c..2fa110018 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -3818,7 +3818,7 @@ def _imported_type_refs(module: SemanticModule) -> dict[str, tuple[str, str, str if isinstance(imp, SemanticImport): for item in imp.items: local_name = item.target or item.source - imported[local_name] = (imp.module, item.source, local_name) + imported[local_name] = (imp.module.lstrip("."), item.source, local_name) if imp.module.startswith("."): imported_namespaces[local_name] = _relative_imported_namespace(imp.module, item.source) continue diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/box_ops.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/box_ops.pyi index ffc6ff07d..5fcc92471 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/box_ops.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/box_ops.pyi @@ -1,5 +1,5 @@ from prik.contracts import Int32 -from shared_types import box +from .shared_types import box def box_value( item: box diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/second_math.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/second_math.pyi index bb8c307a4..bcb952886 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/second_math.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/second_math.pyi @@ -1,5 +1,5 @@ from prik.contracts import Addr, Arg, Int32, native_call -from first_math import add_one +from .first_math import add_one @native_call([Addr(Arg(0))]) def double_after_add( diff --git a/tests/fortran/infrastructure/building/end_to_end/test_multi_source_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_multi_source_builds.py index 9142b41c7..48d110b1f 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_multi_source_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_multi_source_builds.py @@ -298,7 +298,8 @@ def test_multi_source_pyi_out_writes_one_flat_combined_package(tmp_path: Path): assert entry.read_text(encoding="utf-8") == ( "from . import first_math\nfrom . import shared_types\nfrom . import second_math\nfrom . import box_ops\n" ) - assert "shared_types" in (package / "box_ops.pyi").read_text(encoding="utf-8") + assert "from .shared_types import box" in (package / "box_ops.pyi").read_text(encoding="utf-8") + assert "from .first_math import add_one" in (package / "second_math.pyi").read_text(encoding="utf-8") def test_multi_source_generated_contract_build_matches_source_runtime_and_link_order(tmp_path: Path): @@ -331,6 +332,28 @@ def test_multi_source_generated_contract_build_matches_source_runtime_and_link_o ] _assert_combined_runtime(source_module) _assert_combined_runtime(generated_module) + assert generated_module.box_ops.box is generated_module.shared_types.box + + +def test_generated_module_leaf_loads_sibling_type_contract(tmp_path: Path): + sources = _write_combined_sources(tmp_path) + entry = _generate_combined_contract(sources, tmp_path / "contracts") + native_objects = _compile_native_objects(sources, tmp_path / "native") + + module, payload = _build_contract( + entry.parent / "box_ops.pyi", + native_objects, + tmp_path / "leaf_build", + output_name="box_leaf", + ) + + assert payload["sources"] == [ + str(entry.parent / "box_ops.pyi"), + str(entry.parent / "shared_types.pyi"), + ] + box = module.box() + box.value = np.int32(7) + assert module.box_value(box) == np.int32(7) def test_multi_source_modified_entry_preserves_modules_and_adds_documented_alias(tmp_path: Path): diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py index eb989adc3..5e4230e8f 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py @@ -97,6 +97,15 @@ def test_pyi_emission_context_isolates_modules_and_shares_nested_imports(): assert second.contract_import() == "" +def test_printing_loaded_contract_preserves_absolute_support_imports(): + module = _parse_pyi_text( + "from typing import Any\nfrom prik.contracts import Int32\n\ndef identity(value: Int32) -> Int32: ...\n", + module_name="identity", + ) + + assert "from typing import Any" in emit_module(module) + + def test_printer_validation_and_opaque_dependency_edge_cases(): printer = PyiPrinter() @@ -283,7 +292,7 @@ def test_emit_import_renames(): code = generate_pyi(source) - assert "from list_input import delete_input_list as delete_input" in code + assert "from .list_input import delete_input_list as delete_input" in code def test_emit_imported_derived_type_reference_without_reexporting_class(): @@ -302,7 +311,7 @@ def test_emit_imported_derived_type_reference_without_reexporting_class(): stubs = emit_module_stubs(module) code = stubs["physics"] - assert "from types_mod import particle" in code + assert "from .types_mod import particle" in code assert "from . import types_mod" not in code assert "p: particle" in code assert "Addr(particle)" not in code @@ -416,7 +425,7 @@ def test_emit_bare_use_adds_import_for_opaque_dependency_type(): stubs = emit_module_stubs(fortran_module_to_semantic_module(parsed)) assert "import types_mod" in stubs["physics"] - assert "from types_mod import particle" in stubs["physics"] + assert "from .types_mod import particle" in stubs["physics"] assert stubs["types_mod"].endswith("class particle(Opaque):\n pass") From 2a1dd8ffe02315f0cd8fd627d9836b3c7dfd2b85 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 11:04:09 +0100 Subject: [PATCH 15/24] Keep native scopes bare under relative sibling imports A declaration expression naming an imported specification function recorded the import spelling as the function's native scope, so a relative sibling import left the scope as `.extent_helpers` where Fortran names the module `extent_helpers`. Imported type identities were already normalised; this applies the same rule to declaration callables, and fixes the namespace branch beside it, which split on the leading dot and produced an empty name. Assertions across the Fortran, C and round-trip suites pinned the previous absolute spelling and now expect the relative one. The C frontend emits sibling header imports through the same printer, so those move with it. Found by running the full suite, which the relative-import change had not been through: five Fortran and three C failures, one of them this defect and the rest pinned spellings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/pyi2ir.py | 7 +++++-- .../infrastructure/cli/pipeline/test_c_cli_skeleton.py | 2 +- .../semantics/test_projects_and_diagnostics.py | 2 +- tests/c/records/semantics/test_c_record_semantics.py | 2 +- tests/fortran/arrays/semantics/test_array_semantics.py | 4 ++-- .../end_to_end/test_multi_file_contract_generation.py | 10 +++++----- .../semantics/test_round_trip_properties.py | 2 +- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 2fa110018..ce7de243c 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -291,11 +291,14 @@ def _declaration_callable_imports( namespaces: dict[str, str] = {} for imported in self.module.imports: if isinstance(imported, SemanticImport): + # A sibling leaf is imported relatively, but a native scope is + # the module's own name, so the relative marker is dropped. + module_name = imported.module.lstrip(".") if imported.items: for item in imported.items: - explicit[(item.target or item.source).casefold()] = (imported.module, item.source) + explicit[(item.target or item.source).casefold()] = (module_name, item.source) else: - namespaces[imported.module.split(".", 1)[0].casefold()] = imported.module + namespaces[module_name.split(".", 1)[0].casefold()] = module_name continue for item in str(imported).split(","): module_name, _, alias = item.strip().partition(" as ") diff --git a/tests/c/infrastructure/cli/pipeline/test_c_cli_skeleton.py b/tests/c/infrastructure/cli/pipeline/test_c_cli_skeleton.py index 7db7f2f2a..0c00b72d8 100644 --- a/tests/c/infrastructure/cli/pipeline/test_c_cli_skeleton.py +++ b/tests/c/infrastructure/cli/pipeline/test_c_cli_skeleton.py @@ -319,7 +319,7 @@ def test_cli_c_pyi_out_writes_explicit_multi_header_owner_stubs(tmp_path: Path): assert result.stdout == "" assert "class state(CStruct):" in (tmp_path / "types.pyi").read_text(encoding="utf-8") api_stub = (tmp_path / "api.pyi").read_text(encoding="utf-8") - assert "from types import state" in api_stub + assert "from .types import state" in api_stub assert "class state" not in api_stub assert "state: state" in api_stub assert "Addr(state)" not in api_stub diff --git a/tests/c/infrastructure/semantic_ir/semantics/test_projects_and_diagnostics.py b/tests/c/infrastructure/semantic_ir/semantics/test_projects_and_diagnostics.py index 6c95afa23..044f76178 100644 --- a/tests/c/infrastructure/semantic_ir/semantics/test_projects_and_diagnostics.py +++ b/tests/c/infrastructure/semantic_ir/semantics/test_projects_and_diagnostics.py @@ -62,7 +62,7 @@ def test_c2ir_explicit_project_headers_import_types_from_their_owner_module(): "representation": "wrapped", } assert "external_type_ref" not in local_state.metadata - assert "from types import state" in stubs["api"] + assert "from .types import state" in stubs["api"] assert "class state" not in stubs["api"] diff --git a/tests/c/records/semantics/test_c_record_semantics.py b/tests/c/records/semantics/test_c_record_semantics.py index c10e26a03..81e8ee38a 100644 --- a/tests/c/records/semantics/test_c_record_semantics.py +++ b/tests/c/records/semantics/test_c_record_semantics.py @@ -113,7 +113,7 @@ def test_c2ir_private_include_types_remain_available_as_opaque_handles(): "wrapped": False, "representation": "opaque", } - assert "from private import private_context" in stubs["api"] + assert "from .private import private_context" in stubs["api"] assert ( stubs["private"] == "from prik.contracts import CStruct, Opaque\n\nclass private_context(CStruct, Opaque):\n pass" diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index 83f35a5e5..901068c55 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -230,7 +230,7 @@ def test_specification_function_calls_keep_local_and_imported_native_identity(): reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array - assert "from extent_helpers import extent_for as imported_extent" in generated + assert "from .extent_helpers import extent_for as imported_extent" in generated assert "Float64[imported_extent(n), local_extent(n)]" in generated assert reloaded_array.expression_callables == array.expression_callables @@ -271,7 +271,7 @@ def test_wildcard_specification_function_origin_round_trips_unambiguously(): reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array - assert "from extent_helpers import extent_for" in generated + assert "from .extent_helpers import extent_for" in generated assert reloaded_array.expression_callables == array.expression_callables diff --git a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py index d0168d026..a2d3d0f97 100644 --- a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py +++ b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py @@ -118,17 +118,17 @@ def test_multi_file_generation_places_the_prototype_with_its_declaring_module(tm assert "@prototype\ndef OBJ(" in declaring consuming = (contracts / "solver_mod.pyi").read_text(encoding="utf-8") - assert "from pintrf_mod import OBJ" in consuming + assert "from .pintrf_mod import OBJ" in consuming assert "calfun: OBJ" in consuming renamed = (contracts / "renamed_mod.pyi").read_text(encoding="utf-8") - assert "from pintrf_mod import OBJ as LOCAL_OBJ" in renamed + assert "from .pintrf_mod import OBJ as LOCAL_OBJ" in renamed assert "calfun: LOCAL_OBJ" in renamed # A procedure-local rename reaches the contract through the synthetic # prototype import rather than the module's own import list. scoped = (contracts / "scoped_rename_mod.pyi").read_text(encoding="utf-8") - assert "from pintrf_mod import OBJ as SCOPED_OBJ" in scoped + assert "from .pintrf_mod import OBJ as SCOPED_OBJ" in scoped assert "calfun: SCOPED_OBJ" in scoped assert "import SCOPED_OBJ" not in scoped.replace("OBJ as SCOPED_OBJ", "") @@ -312,11 +312,11 @@ def test_renamed_reexport_chain_builds_through_its_generated_contracts(tmp_path: ) # Each contract mirrors the `use` its own module wrote. - assert "from chain_declares_mod import OBJ as MID" in (contracts / "chain_middle_mod.pyi").read_text( + assert "from .chain_declares_mod import OBJ as MID" in (contracts / "chain_middle_mod.pyi").read_text( encoding="utf-8" ) consuming = (contracts / "chain_consumer_mod.pyi").read_text(encoding="utf-8") - assert "from chain_middle_mod import MID as LOCAL" in consuming + assert "from .chain_middle_mod import MID as LOCAL" in consuming assert "calfun: LOCAL" in consuming result = build_pyi_extension( diff --git a/tests/fortran/infrastructure/semantic_pyi/semantics/test_round_trip_properties.py b/tests/fortran/infrastructure/semantic_pyi/semantics/test_round_trip_properties.py index 13cae8693..bdc595c62 100644 --- a/tests/fortran/infrastructure/semantic_pyi/semantics/test_round_trip_properties.py +++ b/tests/fortran/infrastructure/semantic_pyi/semantics/test_round_trip_properties.py @@ -73,7 +73,7 @@ def import_lines(names): ) return [line for line in emit_module(module).splitlines() if line.startswith("from ")] - expected = [f"from types import {', '.join(sorted(type_names))}"] + expected = [f"from .types import {', '.join(sorted(type_names))}"] assert import_lines(type_names) == expected assert import_lines(reversed(type_names)) == expected From 51de26f15a21ecf2b266283823a62ff321438b4e Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 11:39:17 +0100 Subject: [PATCH 16/24] Keep every import when a scope uses one module repeatedly A `use` statement replaced any earlier import of the same module instead of adding to it, so a scope naming one module across several statements kept only the last. PRIMA splits iso_fortran_env across three lines, so `DP => REAL64` was dropped and `real(RP)` could not be resolved from source: the kind reached the compiler probe as a project name the probe cannot see. With every import kept, the existing project symbol table resolves `RP` to `REAL64` and `IK` to `kind(0)`, which the probe evaluates as the intrinsic expressions they are. A bare `use` imports everything, so it absorbs any list beside it rather than being narrowed by one. Ordering a procedure's outputs also compared an unplaced position against placed ones and raised a comparison error. An output with no position is what the check exists to catch, so it is reported as an unsupported wrapper policy instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 ++++ prik/parsers/fortran/parser.py | 30 +++++++++++-- prik/policy/construction.py | 4 ++ .../modules/parsing/test_module_parsing.py | 0 .../modules/parsing/test_scope_handling.py | 42 +++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/fortran/modules/parsing/test_module_parsing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1e95ac4..3cd9a63e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- A scope naming the same module in several `use` statements now keeps every + import. Each statement was replacing the previous one, so only the last + survived; a module splitting a long import list across lines silently lost + the names the earlier lines carried, and any kind parameter among them stopped + resolving. + +- A procedure whose outputs have no completed ordering is now reported as an + unsupported wrapper policy instead of raising a comparison error. + - Generated Fortran module leaves now import sibling contracts relatively, so building a leaf directly loads the contracts its declarations depend on. A native derived type exported through several modules shares one set of diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index c1f3deb89..74a81699f 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -3451,7 +3451,7 @@ def _parse_module_like_spec_line( parsed_use = self._parse_use_statement(stripped) if parsed_use and hasattr(target, "uses"): module_name, mappings = parsed_use - target.uses[module_name] = mappings + self._record_use_mappings(target.uses, module_name, mappings) return if _REGEX["derived_type"].match(stripped): @@ -3619,8 +3619,8 @@ def _parse_procedure_spec_line( parsed_use = self._parse_use_statement(stripped) if parsed_use: module_name, mappings = parsed_use - proc_state.uses[module_name] = mappings - proc_state.local_uses[module_name] = mappings + self._record_use_mappings(proc_state.uses, module_name, mappings) + self._record_use_mappings(proc_state.local_uses, module_name, mappings) return # This parser is a subset parser focused on wrapper-relevant metadata. # These statements do not affect extracted signature typing/shapes. @@ -5716,6 +5716,30 @@ def _bind_c_name(tail: str) -> str | None: name = match.groupdict().get("name") return name if name else None + @staticmethod + def _record_use_mappings( + uses: dict[str, list[FortranUseMapping]], + module_name: str, + mappings: list[FortranUseMapping], + ) -> None: + """Accumulate one ``use`` statement into a scope's import table. + + A scope may name the same module more than once, each statement adding + what it lists, so a later statement extends the imports rather than + replacing them. A bare ``use`` imports everything, which the empty + mapping list already means, and absorbs any list beside it. + """ + existing = uses.get(module_name) + if existing is None or not mappings: + uses[module_name] = mappings + return + if not existing: + return + known = {(item.source.casefold(), (item.target or item.source).casefold()) for item in existing} + existing.extend( + item for item in mappings if (item.source.casefold(), (item.target or item.source).casefold()) not in known + ) + @staticmethod def _parse_use_statement(line: str) -> tuple[str, list[FortranUseMapping]] | None: """Parse a ``use`` statement into its module and explicit mappings.""" diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 54cece543..eedc7f1c9 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -5921,6 +5921,10 @@ def _result_position_blockers( ) if not positions: return () + if any(position is None for position in positions): + # An unplaced output has no position to order, which this check reports + # rather than comparing against the positions that do exist. + return (f"binding result positions are incomplete; received {positions}",) if sorted(positions) == list(range(len(positions))) and len(set(positions)) == len(positions): return () return (f"binding result positions must cover 0..{len(positions) - 1} exactly once; received {positions}",) diff --git a/tests/fortran/modules/parsing/test_module_parsing.py b/tests/fortran/modules/parsing/test_module_parsing.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fortran/modules/parsing/test_scope_handling.py b/tests/fortran/modules/parsing/test_scope_handling.py index 3e5867c6c..8f926e1d5 100644 --- a/tests/fortran/modules/parsing/test_scope_handling.py +++ b/tests/fortran/modules/parsing/test_scope_handling.py @@ -188,3 +188,45 @@ def test_fortran_parser_class_entrypoint(): assert len(signatures) == 1 assert signatures[0].name == "touch" + + +def test_repeated_use_of_one_module_accumulates_its_imports(): + """A scope may name the same module in several `use` statements. + + Each statement adds what it lists, so a later one extends the imports + rather than replacing them; real sources split long import lists this way, + and dropping the earlier statements loses the names they carried. + """ + module = parse_fortran_file( + """ +module consumer_mod + use, intrinsic :: iso_fortran_env, only : INT32, SP => REAL32, DP => REAL64 + use, intrinsic :: iso_fortran_env, only : QP => REAL128 + use, intrinsic :: iso_fortran_env, only : STDOUT => OUTPUT_UNIT + implicit none +end module consumer_mod +""" + ).modules[0] + + assert [(item.source, item.target) for item in module.uses["iso_fortran_env"]] == [ + ("INT32", None), + ("REAL32", "SP"), + ("REAL64", "DP"), + ("REAL128", "QP"), + ("OUTPUT_UNIT", "STDOUT"), + ] + + +def test_a_bare_use_absorbs_the_named_imports_of_the_same_module(): + """Importing everything subsumes any list beside it.""" + module = parse_fortran_file( + """ +module wide_mod + use kinds_mod, only : rk + use kinds_mod + implicit none +end module wide_mod +""" + ).modules[0] + + assert module.uses["kinds_mod"] == [] From bf935a6542527f5ff0c43341e3cf09320a2e54bc Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 11:39:44 +0100 Subject: [PATCH 17/24] Remove an empty test module left by a probe Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- tests/fortran/modules/parsing/test_module_parsing.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/fortran/modules/parsing/test_module_parsing.py diff --git a/tests/fortran/modules/parsing/test_module_parsing.py b/tests/fortran/modules/parsing/test_module_parsing.py deleted file mode 100644 index e69de29bb..000000000 From 0a040df295be4dec384958ebdec7b308116e2ced Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 12:07:32 +0100 Subject: [PATCH 18/24] Collect every specific a split generic binding names A derived type may build one type-bound generic from several `generic ::` statements, each contributing specifics. The parser recorded one binding per statement, so a type declaring generic :: area => area_int generic :: area => area_real carried two bindings both named `area`. Only the first reached dispatch, and calling the generic with the argument types of any later statement raised `no matching overload` at runtime. The single-statement spelling worked, so whether a call resolved depended on how the source was written. The generated contract hid this: its printer renders same-named overload sets as consecutive `@overload` defs, which is what Python wants, so both spellings produced byte-identical `.pyi` text and the loss surfaced only in the built extension. Merge the statements where the module-level generic interface blocks are already merged. The key ignores case and internal spacing so a defined operator merges across `operator(+)` and `operator (+)`. Attributes come from the first statement: the standard requires every statement for one binding to declare the same accessibility. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++ prik/parsers/fortran/parser.py | 22 +++++- .../parsing/test_generic_interface_syntax.py | 71 +++++++++++++++++++ .../test_fortran_generic_semantics.py | 41 +++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd9a63e6..f7e7ceab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A derived type building one generic binding from several `generic ::` + statements now collects every specific into that binding. Each statement was + recorded as its own binding of the same name, so only the first reached + dispatch and calling the generic with the argument types of any later + statement raised `no matching overload`. + - A scope naming the same module in several `use` statements now keeps every import. Each statement was replacing the previous one, so only the last survived; a module splitting a long import list across lines silently lost diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 74a81699f..3ae3b5cb8 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -3744,6 +3744,23 @@ def _parse_type_spec_line( code="PARSE_UNSUPPORTED_DECLARATION", ) + @staticmethod + def _record_generic_binding(dtype: FortranDerivedType, binding: dict) -> None: + """Record one ``generic ::`` statement on a derived type. + + Fortran lets a type-bound generic be built from several statements in + one type, each contributing specifics. They name one binding, so the + parser reports one record carrying every target in declaration order. + The standard requires every statement for a binding to declare the same + accessibility, so the first statement's attributes stand for the rest. + """ + key = "".join(str(binding["name"]).split()).lower() + for existing in dtype.generic_bindings: + if "".join(str(existing["name"]).split()).lower() == key: + existing["targets"].extend(binding["targets"]) + return + dtype.generic_bindings.append(binding) + @staticmethod def _apply_default_component_visibility( dtype: FortranDerivedType, @@ -3801,13 +3818,14 @@ def _parse_derived_type_contains_line( attrs = [a.strip().lower() for a in split_csv(attr_txt)] if attr_txt else [] lhs, rhs_txt = [x.strip() for x in right.split("=>", 1)] rhs = [r.strip() for r in split_csv(rhs_txt)] - dtype.generic_bindings.append( + self._record_generic_binding( + dtype, { "name": lhs, "targets": rhs, "attrs": attrs, "visibility": _binding_visibility(attrs, dtype.binding_visibility), - } + }, ) return diff --git a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py index f2cf65406..18f82c456 100644 --- a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py +++ b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py @@ -180,3 +180,74 @@ def test_repeated_generic_names_stay_separate_per_module(): assert [item.specific_procedures for item in modules["first_mod"].interfaces if item.name] == [["report_first"]] assert [item.specific_procedures for item in modules["second_mod"].interfaces if item.name] == [["report_second"]] + + +def test_type_bound_generic_declared_in_several_statements_becomes_one_binding(): + """A type-bound generic collects specifics from as many statements as it takes. + + A derived type may name one generic binding over several ``generic ::`` + statements, and every statement contributes specifics to that one binding + rather than declaring another of the same name. + """ + source = """ +module shape_mod + implicit none + type :: shape_t + real(8) :: v + contains + procedure :: area_int + procedure :: area_real + generic :: area => area_int + generic :: area => area_real + end type shape_t +contains + real(8) function area_int(self, k) + class(shape_t), intent(in) :: self + integer, intent(in) :: k + area_int = self%v * k + end function area_int + real(8) function area_real(self, k) + class(shape_t), intent(in) :: self + real(8), intent(in) :: k + area_real = self%v * k + end function area_real +end module shape_mod +""" + + module = parse_fortran_module(source) + + assert [binding["name"] for binding in module.derived_types[0].generic_bindings] == ["area"] + assert module.derived_types[0].generic_bindings[0]["targets"] == ["area_int", "area_real"] + + +def test_type_bound_operator_generic_merges_across_statements_and_spacing(): + """One defined operator binding survives being split across statements.""" + source = """ +module vec_mod + implicit none + type :: vec_t + real(8) :: v + contains + procedure :: add_int + procedure :: add_real + generic :: operator(+) => add_int + generic :: operator (+) => add_real + end type vec_t +contains + type(vec_t) function add_int(self, k) + class(vec_t), intent(in) :: self + integer, intent(in) :: k + add_int%v = self%v + k + end function add_int + type(vec_t) function add_real(self, k) + class(vec_t), intent(in) :: self + real(8), intent(in) :: k + add_real%v = self%v + k + end function add_real +end module vec_mod +""" + + module = parse_fortran_module(source) + + assert [binding["name"] for binding in module.derived_types[0].generic_bindings] == ["operator(+)"] + assert module.derived_types[0].generic_bindings[0]["targets"] == ["add_int", "add_real"] diff --git a/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py b/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py index dd1f0b5b1..bf4e15567 100644 --- a/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py +++ b/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py @@ -169,3 +169,44 @@ def test_converter_preserves_defined_operators_assignment_and_type_bound_operato assert [ (item.name, [procedure.name for procedure in item.procedures]) for item in classes["counter"].overload_sets ] == [("__add__", ["counter_add_integer"])] + + +def test_type_bound_generic_split_across_statements_reaches_one_overload_set(): + """Every specific a split generic binding names stays reachable. + + A type-bound generic built from several ``generic ::`` statements means one + binding, so the class carries a single overload set holding every specific + -- not one set per statement, which leaves all but the first unreachable at + dispatch. + """ + source = """ +module shape_mod + implicit none + type :: shape_t + real(8) :: v + contains + procedure :: area_integer + procedure :: area_real + generic :: area => area_integer + generic :: area => area_real + end type shape_t +contains + real(8) function area_integer(self, scale) + class(shape_t), intent(in) :: self + integer, intent(in) :: scale + area_integer = self%v * scale + end function area_integer + real(8) function area_real(self, scale) + class(shape_t), intent(in) :: self + real(8), intent(in) :: scale + area_real = self%v * scale + end function area_real +end module shape_mod +""" + + module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + + shape = module.classes[0] + assert [(item.name, [proc.name for proc in item.procedures]) for item in shape.overload_sets] == [ + ("area", ["area_integer", "area_real"]) + ] From 3f7955ad5f8a7d76694fa3701eef69dbfc5578ed Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 12:23:04 +0100 Subject: [PATCH 19/24] Resolve a kind an interface body names for itself An interface body types its own dummies, and the kind it names may come from a `use` written inside that body -- no module variable or module procedure declares it. The variable-context walk visited a module's variables, procedures and derived types but never its interfaces, so those dummies contributed no target-probe requirement and the conversion later raised on a storage fact nothing had measured. The two input routes disagreed as a result: `generate --pyi` failed with `Unsupported Fortran semantic type for variable 'nf': integer(kind=kind(0))` on sources that `build_fortran_extension` accepted, because a wrapper build's larger parsed set happened to raise the same requirement elsewhere. Walk the interfaces a file or module declares, and report the variables of the bodies they hold. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 +++ prik/semantics/fortran2ir.py | 22 +++++++++++ .../test_fortran_scalar_semantics.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e7ceab6..a09e9710d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract generated from a source whose abstract interface types a dummy + through a kind of its own now resolves that kind. An interface body's + variables reached no target probe, so a kind named only there -- through a + `use` written inside the body -- had no storage fact and `generate --pyi` + failed on a declaration the wrapper build accepted. + - A derived type building one generic binding from several `generic ::` statements now collects every specific into that binding. Each statement was recorded as its own binding of the same name, so only the first reached diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index cac206ddb..94d1ba200 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -3550,6 +3550,7 @@ def _visit_FortranFile(self, node: FortranFile, **_context): node.block_data_units, node.procedures, node.derived_types, + node.interfaces, ) for collection in collections: for child in collection: @@ -3584,6 +3585,25 @@ def _visit_FortranBlockData(node: FortranBlockData, **_context): for variable in node.variables ) + def _visit_FortranInterface( + self, + node: FortranInterface, + *, + module_name: str | None = None, + **_context, + ): + """Return the variable contexts an interface body declares. + + An interface body types its own dummies, and the kind it names may come + from a ``use`` written inside that body. Those variables reach a target + probe only from here, since no module variable or module procedure + declares them. + """ + contexts = [] + for procedure in node.procedures: + contexts.extend(self._visit(procedure, module_name=module_name or node.module)) + return tuple(contexts) + @staticmethod def _visit_FortranProcedureSignature( node: FortranProcedureSignature, @@ -3645,6 +3665,8 @@ def _module_variable_contexts( contexts.extend(self._visit(procedure, module_name=owner)) for derived_type in node.derived_types: contexts.extend(self._visit(derived_type, module_name=owner)) + for interface in node.interfaces: + contexts.extend(self._visit(interface, module_name=owner)) return tuple(contexts) diff --git a/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py b/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py index 1add7b3ef..bed918970 100644 --- a/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py +++ b/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py @@ -203,3 +203,40 @@ def test_legacy_fortran_storage_uses_fixed_star_widths_and_probes_double_types() ("real", "kind(1.0d0)", "storage_size(real(0.0,kind=kind(1.0d0)))"), ("complex", "kind(1.0d0)", "storage_size(cmplx(0.0,kind=kind(1.0d0)))"), } + + +def test_interface_body_dummies_require_target_storage_facts(): + """An interface body's dummies reach the target probe like any other variable. + + An abstract interface names its own kinds, often through a ``use`` written + inside the body, and no module variable or module procedure declares them. + Collecting nothing for such a body leaves the conversion without the storage + fact it later demands. + """ + source = """ +module callback_mod + implicit none + private + public :: reporter + abstract interface + subroutine reporter(x, nf) + use kind_mod, only : rp, ik + implicit none + real(rp), intent(in) :: x + integer(ik), intent(in) :: nf + end subroutine reporter + end interface +end module callback_mod +""" + + parsed = parse_fortran_source(source) + + requirements = collect_fortran_type_storage_requirements( + parsed, + compile_time_values={"rp": "kind(0.0d0)", "ik": "kind(0)"}, + ) + + assert [requirement["expression"] for requirement in requirements] == [ + "storage_size(real(0.0,kind=kind(0.0d0)))", + "storage_size(int(0,kind=kind(0)))", + ] From 586e4e686c91eeb8dee81413982d88b415f2b935 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 12:39:27 +0100 Subject: [PATCH 20/24] Accept an overload declaration that restates a projected result An overload declaration names a specific and restates its public signature. The projection that shapes that signature lives on the specific -- a declaration carrying `native_call` is rejected outright -- so the declaration can only spell what the projection leaves visible. The comparison read the native form instead, and rejected two shapes a generated contract routinely holds. An output argument projected into a result kept the write-through its argument passing states. Whether the call writes through a dummy is not part of a result type, and the comparison already read ownership from the declaration for that reason; its storage mutability now follows. A native scalar descriptor result kept its descriptor topology, which only a `native_call` result wrapper can name. The contract printer already strips it when emitting such a result as a nullable value, and the comparison now expects what the printer writes. Reading that annotation back needed the `| None` unwrapped as well, which until now happened only for a slot some projection marked nullable. The effect was a contract the same tool refused to read back: a generic over `intent(out)` allocatable arguments, such as an allocation helper, failed on `safealloc` against its first specific. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 ++ prik/semantics/pyi2ir.py | 50 ++++++++++- .../semantics/test_pyi_overload_semantics.py | 85 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09e9710d..f34bc8ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- An overload declaration whose specific projects an output argument into its + result is now accepted. The check compared the declared result against the + projected one including the write-through the native argument passing states, + and a native scalar descriptor result including the descriptor topology that + only a `native_call` result wrapper can name -- neither of which a declared + result type spells. A generated contract carrying such a generic, for example + one over `intent(out)` allocatable arguments, was rejected on read-back by the + same tool that wrote it. + - A contract generated from a source whose abstract interface types a dummy through a kind of its own now resolves that kind. An interface body's variables reached no target probe, so a kind named only there -- through a diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index ce7de243c..f7cf1f2c7 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -539,6 +539,7 @@ def function_def( has_native_call: bool = False, release_gil: bool = False, error_status_policy: dict[str, object] | None = None, + restates_projected_result: bool = False, ) -> SemanticFunction: """Convert a module-level stub into a semantic function declaration. @@ -552,6 +553,7 @@ def function_def( node, projection=actual_projection, native_result=native_result, + restates_projected_result=restates_projected_result, ) metadata = {BIND_TARGET_METADATA: native_name} if native_name is not None else {} if has_native_call: @@ -650,6 +652,7 @@ def method_def( release_gil: bool = False, error_status_policy: dict[str, object] | None = None, deferred: bool = False, + restates_projected_result: bool = False, ) -> SemanticMethod: """Convert a class stub into a semantic method declaration. @@ -664,6 +667,7 @@ def method_def( projection=actual_projection, native_result=native_result, drop_untyped_self=True, + restates_projected_result=restates_projected_result, ) metadata = {BIND_TARGET_METADATA: native_name} if native_name is not None else {} if deferred: @@ -1278,15 +1282,47 @@ def _validate_overload_signature( @staticmethod def _matches_projected_return(declared, target_return) -> bool: - """Compare a declared result with a target's, ignoring result ownership.""" + """Compare a declared result with a target's, ignoring result ownership. + + A projected output is written through as a native argument and returned + as an ordinary result. Whether the call writes it is a property of that + argument passing, which a declared result type does not state, so the + comparison reads it from the declaration rather than the target. + """ declared_type = _PyiAstParser._visible_overload_type(declared) target_type = _PyiAstParser._visible_overload_type(target_return) if declared_type is None or target_type is None: return declared_type == target_type - expected = deepcopy(target_type) + declared_type = deepcopy(declared_type) + # An unwrapped `| None` leaves a parse marker behind when no projection + # consumes it, which names nothing about the type itself. + declared_type.metadata.pop(_PYI_OPTIONAL_RETURN_METADATA, None) + expected = _PyiAstParser._visible_projected_result(target_type) expected.ownership = deepcopy(declared_type.ownership) + if expected.storage is not None and declared_type.storage is not None: + expected.storage.read_only = declared_type.storage.read_only + expected.storage.mutable = declared_type.storage.mutable return declared_type == expected + @staticmethod + def _visible_projected_result(target_type: SemanticType) -> SemanticType: + """Return the public result form a declaration can spell for a projection. + + A native scalar descriptor result is written as a nullable value plus a + `native_call` result wrapper naming the descriptor, and an overload + declaration carries no `native_call`. Its descriptor topology therefore + has no place in the declared annotation, exactly as the contract printer + emits it. + """ + expected = deepcopy(target_type) + if _PyiAstParser._semantic_scalar_descriptor_kind(expected) is None: + return expected + for key in ("fortran_allocatable", "fortran_pointer", "fortran_pointer_association"): + expected.metadata.pop(key, None) + if expected.storage is not None and expected.storage.kind in {"reference", "pointer", "address"}: + expected.storage = None + return expected + @staticmethod def _projected_overload_arguments( function: SemanticFunction, @@ -3021,6 +3057,7 @@ def _callable_parts( projection: list[ProjectionMapping], native_result: ProjectionMapping | None = None, drop_untyped_self: bool = False, + restates_projected_result: bool = False, ) -> tuple[list[SemanticArgument], SemanticType | None]: """Build a callable's arguments, results, and native projection metadata. @@ -3038,6 +3075,13 @@ def _callable_parts( # Construct direct and projected outputs from the Python return shape. optional_return_positions = self._optional_native_return_positions(projection, native_result) + if restates_projected_result: + # An overload declaration restates the result its specific projects, + # and the projection that makes a slot nullable lives on that + # specific -- a declaration carrying one is rejected outright. Read + # every slot of such a declaration as nullable so it can spell the + # result the specific already produces. + optional_return_positions = set(range(len(self.return_items(node.returns)))) return_type, returned_args = self.return_projection( node.returns, optional_return_positions=optional_return_positions, @@ -3599,6 +3643,7 @@ def _visit_FunctionDef(self, node: ast.FunctionDef) -> None: release_gil=decorators.release_gil, error_status_policy=decorators.error_status_policy, deferred=decorators.abstract_method, + restates_projected_result=decorators.overload_target is not None, ) self.parser._reject_private_constructor(node.name, decorators.visibility) if node.name == "__init__" and decorators.bind_target is not None and decorators.overload_target is None: @@ -3760,6 +3805,7 @@ def _visit_FunctionDef(self, node: ast.FunctionDef) -> None: has_native_call=decorators.has_native_call, release_gil=decorators.release_gil, error_status_policy=decorators.error_status_policy, + restates_projected_result=decorators.overload_target is not None, ) if decorators.overload_target is not None: self.parser._pending_overloads.append( diff --git a/tests/fortran/generic_interfaces/semantics/test_pyi_overload_semantics.py b/tests/fortran/generic_interfaces/semantics/test_pyi_overload_semantics.py index c610f0129..8cc96bf54 100644 --- a/tests/fortran/generic_interfaces/semantics/test_pyi_overload_semantics.py +++ b/tests/fortran/generic_interfaces/semantics/test_pyi_overload_semantics.py @@ -108,3 +108,88 @@ def set(self, value: Int32) -> None: ... def test_convert_pyi_to_ir_rejects_invalid_prik_overload_links(source: str, message: str): with pytest.raises(ValueError, match=message): parse_pyi_text(source, module_name="generic_mod") + + +def test_overload_accepts_a_specific_that_projects_an_output_array_to_its_result(): + """A projected array output matches a declared result that states no mutability. + + An `intent(out)` allocatable dummy is written through as an argument, and a + projection returns it as an ordinary result. That write-through belongs to + the argument passing, so a declared result type -- which states no such + thing -- still names the same value. + """ + module = parse_pyi_text( + """ +@native_call([Return('x', 0), Addr(Arg(0))]) +def alloc_vector(n: Int32) -> Allocatable[Int32[:]]: ... + +@bind("safealloc") +@overload("alloc_vector") +def safealloc(n: Int32) -> Allocatable[Int32[:]]: ... +""", + module_name="memory_mod", + ) + + assert [(item.name, [procedure.name for procedure in item.procedures]) for item in module.overload_sets] == [ + ("safealloc", ["alloc_vector"]) + ] + + +@pytest.mark.parametrize( + "declared_result", + ["Allocatable[Float64[:]]", "Allocatable[Int32[:, :]]", "Int32"], +) +def test_overload_still_rejects_a_projected_result_of_another_type(declared_result: str): + """Neutralizing write-through leaves every other result difference compared.""" + source = f""" +@native_call([Return('x', 0), Addr(Arg(0))]) +def alloc_vector(n: Int32) -> Allocatable[Int32[:]]: ... + +@bind("safealloc") +@overload("alloc_vector") +def safealloc(n: Int32) -> {declared_result}: ... +""" + + with pytest.raises(ValueError, match="declaration 'safealloc' is incompatible"): + parse_pyi_text(source, module_name="memory_mod") + + +def test_overload_accepts_a_specific_that_projects_a_scalar_descriptor_to_its_result(): + """A nullable descriptor result is the only form an overload can restate. + + A native scalar descriptor result is written as a nullable value plus a + `native_call` result wrapper, and an overload declaration may carry no + `native_call`. The declaration therefore spells the visible value alone, as + the contract printer emits it. + """ + module = parse_pyi_text( + """ +@native_call([Allocatable(Return('x', 0)), Addr(Arg(0))]) +def alloc_character(n: Int32) -> String[:] | None: ... + +@bind("safealloc") +@overload("alloc_character") +def safealloc(n: Int32) -> String[:] | None: ... +""", + module_name="memory_mod", + ) + + assert [(item.name, [procedure.name for procedure in item.procedures]) for item in module.overload_sets] == [ + ("safealloc", ["alloc_character"]) + ] + + +@pytest.mark.parametrize("declared_result", ["String", "Int32[:] | None", "Float64[:] | None"]) +def test_overload_still_rejects_a_projected_descriptor_of_another_type(declared_result: str): + """Reading a descriptor result as nullable leaves every other difference compared.""" + source = f""" +@native_call([Allocatable(Return('x', 0)), Addr(Arg(0))]) +def alloc_character(n: Int32) -> String[:] | None: ... + +@bind("safealloc") +@overload("alloc_character") +def safealloc(n: Int32) -> {declared_result}: ... +""" + + with pytest.raises(ValueError, match="declaration 'safealloc' is incompatible"): + parse_pyi_text(source, module_name="memory_mod") From 6ec888d0a745d9529b02d33724f1daae03666e5b Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 13:01:06 +0100 Subject: [PATCH 21/24] Import each name as the contract defining it spells it A source-derived contract declares its entities under Python names, so a Fortran entity kept in capitals is declared lower case with its source spelling recorded beside it. Imports were written straight from the parser's `use` mapping instead, leaving a contract that defines `ik` imported as `IK` -- a name nothing defines, which failed when the package was loaded back. A prototype is the exception. It keeps the spelling its own contract declares, because an annotation naming it is written the same way, so an import binding one keeps that spelling too. Which names those are is a fact about the contracts that declare them, not the one reading them: a module re-exporting a prototype references it nowhere in its own body. The stub emitter already holds every module it renders, so it collects the prototypes they declare and tells each module before any of them writes an import. Either spelling in a renamed import identifies a prototype -- the source names what the dependency declares, the target what the importer calls it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 8 ++ prik/pipeline/pyi.py | 7 ++ prik/printers/pyi.py | 114 ++++++++++++++++-- .../test_pyi_printer_imports_and_packages.py | 96 +++++++++++++++ 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f34bc8ec8..1a4b4c52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generated contract now imports each name under the spelling the contract + that defines it uses. A source-derived contract declares a Fortran entity + under a Python name, so one spelled in capitals is declared lower case, while + the import kept asking for the source spelling and named nothing the + dependency defines -- loading the package back failed on it. A prototype is + unchanged: it keeps its declared spelling wherever it is written, so an import + binding one keeps it too. + - An overload declaration whose specific projects an output argument into its result is now accepted. The check compared the declared result against the projected one including the write-through the native argument passing states, diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index ef5f2fb94..2a397bf43 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -131,10 +131,17 @@ def emit_module_stubs( target.classes.extend(cls for cls in dependency.classes if cls.name not in existing) complete_semantic_policies(module for module in emitted_modules.values() if module.origin.source_language != "c") + # A prototype keeps the spelling its own contract declares, so every module + # rendered here is told which names those are before any of them writes an + # import binding one. + declared_prototype_names = { + str(prototype.name) for module in emitted_modules.values() for prototype in module.prototypes + } return { module_name: emit_module( module, normalize_fortran_public_names=normalize_fortran_public_names, + declared_prototype_names=declared_prototype_names, ).strip() for module_name, module in emitted_modules.items() } diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 3ba5c80a6..7a966a4cc 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -19,6 +19,7 @@ from prik.codegen.primitive_scalar_types import NumpyDtypeRegistry from prik.contracts import CONTRACT_SYMBOLS, CONTRACT_TYPE_NAMES from prik.naming import NamingPolicy +from prik.naming.policy import normalize_public_name from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, @@ -161,13 +162,22 @@ class PyiPrinter(ClassVisitor): # Public entrypoints and state # ------------------------------------------------------------------ - def __init__(self, *, normalize_fortran_public_names: bool = False): + def __init__( + self, + *, + normalize_fortran_public_names: bool = False, + declared_prototype_names: Iterable[str] = (), + ): """Configure public-name normalization for independent emissions. Set normalize_fortran_public_names when emitting source-derived Fortran - contracts whose public names need Python normalization. + contracts whose public names need Python normalization. Pass + declared_prototype_names when rendering one module alongside others, so + an import naming a prototype another contract declares is written under + the spelling that contract keeps. """ self._normalize_fortran_public_names = normalize_fortran_public_names + self._declared_prototype_names = frozenset(str(name) for name in declared_prototype_names) def emit(self, node) -> str: """Render one supported semantic model to semantic .pyi text. @@ -1445,8 +1455,16 @@ def _append_imports( if contract_import: sections.append(contract_import) imports = self._effective_imports(module) + verbatim = self._verbatim_import_names(module) for imp in imports: - sections.append(self._emit_import(imp, native_source=not module.metadata.get(PYI_LOADED_METADATA))) + sections.append( + self._emit_import( + imp, + native_source=not module.metadata.get(PYI_LOADED_METADATA), + public_names=context.normalize_fortran_public_names, + verbatim_names=verbatim, + ) + ) if contract_import or imports: sections.append("") @@ -1737,23 +1755,87 @@ def class_has_overloads(cls: SemanticClass) -> bool: ) @staticmethod - def _emit_import(imp: str | SemanticImport, *, native_source: bool = False) -> str: + def _emit_import( + imp: str | SemanticImport, + *, + native_source: bool = False, + public_names: bool = False, + verbatim_names: frozenset[str] = frozenset(), + ) -> str: """Emit import syntax.""" if isinstance(imp, str): return f"import {imp}" if not imp.items: return f"import {imp.module}" - items = ", ".join(PyiPrinter._emit_import_item(item) for item in imp.items) + items = ", ".join( + PyiPrinter._emit_import_item(item, public_names=public_names, verbatim_names=verbatim_names) + for item in imp.items + ) module_name = f".{imp.module}" if native_source and not imp.module.startswith(".") else imp.module return f"from {module_name} import {items}" @staticmethod - def _emit_import_item(item: SemanticImportItem) -> str: - """Emit import item syntax.""" + def _emit_import_item( + item: SemanticImportItem, + *, + public_names: bool = False, + verbatim_names: frozenset[str] = frozenset(), + ) -> str: + """Emit import item syntax. + + An import names what the module it reads from publishes. Where a + source-derived contract writes its declarations under Python names, the + names it imports are spelled that way too -- a source keeping a Fortran + entity in capitals declares it lower case, and an importer asking for + the source spelling asks for a name no contract defines. A prototype is + the exception: it keeps its declared spelling wherever it is written, + because an annotation naming it is written the same way. + """ + # The source names what the dependency declares and the target what + # this contract calls it; either spelling identifies a prototype. + if item.source in verbatim_names or (item.target or item.source) in verbatim_names: + return PyiPrinter._verbatim_import_item(item) + source = PyiPrinter._public_import_name(item.source, public_names=public_names) + target = PyiPrinter._public_import_name(item.target, public_names=public_names) + if target and target != source: + return f"{source} as {target}" + return source + + @staticmethod + def _verbatim_import_item(item: SemanticImportItem) -> str: + """Emit one import item under the spelling its declaration keeps.""" if item.target and item.target != item.source: return f"{item.source} as {item.target}" return item.source + @staticmethod + def _public_import_name(name: str | None, *, public_names: bool) -> str | None: + """Return one imported name as the contract that defines it spells it.""" + if not public_names or not name or name == "*": + return name + return normalize_public_name(name).name + + def _verbatim_import_names(self, module: SemanticModule) -> frozenset[str]: + """Return imported names a contract writes under their declared spelling. + + A prototype keeps the spelling its own contract declares, and an + annotation naming one is written the same way, so an import binding it + keeps that spelling too. Which names those are is a fact about the + contracts that declare them, so it comes from the modules rendered + together with this one; a prototype this module declares itself and one + an annotation here already resolved are known without them. + """ + names = set(self._declared_prototype_names) + names.update(str(prototype.name) for prototype in module.prototypes) + for semantic_type in _module_semantic_types(module): + reference = semantic_type.metadata.get(PROTOTYPE_REF_METADATA) + if not isinstance(reference, dict): + continue + local_name = reference.get("local_name") or reference.get("name") + if local_name: + names.add(str(local_name)) + return frozenset(names) + def _append_items(self, sections: list[str], items: list, emit_item) -> None: """Append items.""" for item in items: @@ -2589,15 +2671,25 @@ def _parameter_target(name: str) -> str: _DEFAULT_PRINTER = PyiPrinter() -def emit_module(module: SemanticModule, *, normalize_fortran_public_names: bool = False) -> str: +def emit_module( + module: SemanticModule, + *, + normalize_fortran_public_names: bool = False, + declared_prototype_names: Iterable[str] = (), +) -> str: """Render one semantic module through the shared default printer. Use this convenience entrypoint for ordinary one-module emission. Set normalize_fortran_public_names to use a printer configured for normalized - public names. Both paths create a fresh module emission context. + public names, and declared_prototype_names to name the prototypes the + modules rendered alongside this one declare. Both paths create a fresh + module emission context. """ - if normalize_fortran_public_names: - return PyiPrinter(normalize_fortran_public_names=True).emit(module) + if normalize_fortran_public_names or declared_prototype_names: + return PyiPrinter( + normalize_fortran_public_names=normalize_fortran_public_names, + declared_prototype_names=declared_prototype_names, + ).emit(module) return _DEFAULT_PRINTER.emit(module) diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py index 5e4230e8f..06d1f1164 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py @@ -514,3 +514,99 @@ def test_emit_module_aliases_standalone_only_for_actual_name_collisions(): assert "@prik_standalone\ndef standalone() -> Int32: ..." in colliding assert "standalone as prik_standalone_2" in twice_colliding.splitlines()[0] assert "@prik_standalone_2\ndef standalone() -> Int32: ..." in twice_colliding + + +def test_generated_contract_imports_a_name_under_the_spelling_its_definition_uses(): + """An import binds the name the module it reads from actually defines. + + A source-derived contract writes its declarations under Python names, so a + Fortran entity spelled in capitals is declared lower case. An import asking + for the source spelling names nothing the dependency contract defines, and + loading the package back fails on it. + """ + consts = parse_fortran_source(""" +module consts_mod +implicit none +integer, parameter :: IK = 4 +end module consts_mod +""") + infos = parse_fortran_source(""" +module infos_mod +use consts_mod, only : IK +implicit none +end module infos_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(consts), fortran_module_to_semantic_module(infos)], + normalize_fortran_public_names=True, + ) + + assert 'ik: Final[Annotated[Int32, SourceName("IK")]]' in stubs["consts_mod"] + assert "from .consts_mod import ik" in stubs["infos_mod"] + assert "import IK" not in stubs["infos_mod"] + + +def test_generated_contract_renames_an_imported_name_under_both_spellings(): + """A renamed import binds the defined name to this contract's own name.""" + consts = parse_fortran_source(""" +module consts_mod +implicit none +integer, parameter :: IK = 4 +end module consts_mod +""") + renaming = parse_fortran_source(""" +module renaming_mod +use consts_mod, only : MY_IK => IK +implicit none +end module renaming_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(consts), fortran_module_to_semantic_module(renaming)], + normalize_fortran_public_names=True, + ) + + assert "from .consts_mod import ik as my_ik" in stubs["renaming_mod"] + + +def test_generated_contract_imports_a_prototype_under_its_declared_spelling(): + """A prototype keeps its spelling, so the import that binds it keeps it too. + + A contract writes a prototype under the name its own declaration states, and + an annotation naming that prototype is written the same way, so normalizing + the import would bind a name no declaration defines. + """ + declares = parse_fortran_source(""" +module pintrf_mod +implicit none +private +public :: OBJ +abstract interface +subroutine OBJ(x) +implicit none +real(8), intent(in) :: x(:) +end subroutine OBJ +end interface +end module pintrf_mod +""") + solver = parse_fortran_source(""" +module solver_mod +use pintrf_mod, only : OBJ +implicit none +contains +subroutine solve(calfun, x) +procedure(OBJ) :: calfun +real(8), intent(inout) :: x(:) +end subroutine solve +end module solver_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(declares), fortran_module_to_semantic_module(solver)], + normalize_fortran_public_names=True, + ) + + assert "def OBJ(" in stubs["pintrf_mod"] + assert "from .pintrf_mod import OBJ" in stubs["solver_mod"] + assert "calfun: OBJ" in stubs["solver_mod"] From 6f439d38b36ba252b1eba6f8b56ff34252cdb697 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 14:13:34 +0100 Subject: [PATCH 22/24] Let a contract rename the entity a declaration reaches A contract is written to be edited, and the name a declaration states is what Python should call the entity. `SourceName` was read the other way round: the source spelling replaced the declared name, so renaming a variable exported the native spelling and dropped the edit entirely. It records the native entity now, exactly as `bind` does for a callable, and the declared name stands. A source name inside `Final[...]` reaches its declaration as well, where the reader looked only through a bare `Annotated` and dropped it. Generated contracts were caught by this too. A Fortran entity Python cannot spell is declared under a name that it can -- `lambda` becomes `lambda_` -- and reading that back installed the unusable spelling, so the declaration the contract stated was unreachable. A class may state a native type through `bind`, which was refused outright, leaving a derived type locked to a name its Fortran type also answers to. Policy already read `native_name or name`, so only the refusal and the reference lookup had to change: an imported reference names a type the way its declaring contract writes it, and resolving it searches that module alone, never a type of the same name elsewhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 14 ++++++ prik/policy/construction.py | 10 +++- prik/semantics/pyi2ir.py | 28 +++++++++-- .../parsing/test_python_ast_contracts.py | 3 +- .../semantics/test_types_and_values.py | 46 ++++++++++++++++++- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4b4c52b..b995c216f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract can now rename what it declares. `SourceName` states the native + entity a variable or constant reaches, the way `bind` already did for a + callable, instead of replacing the name the declaration states -- editing a + contract to give an entity a Python name exported the source spelling and + dropped the edit. A source name inside `Final[...]` reaches its declaration + as well, where it was previously ignored. A generated contract is affected + too: a Fortran entity Python cannot spell, such as one named `lambda`, is + declared as `lambda_` and now stays reachable under that name. + +- A class can state the native type it reaches through `bind`, so a derived + type can be exported under a different Python name. An imported class + reference resolves through the name its declaring contract states, and a + renamed class keeps its `bind` when the contract is regenerated. + - A generated contract now imports each name under the spelling the contract that defines it uses. A source-derived contract declares a Fortran entity under a Python name, so one spelled in capitals is declared lower case, while diff --git a/prik/policy/construction.py b/prik/policy/construction.py index eedc7f1c9..4ef4faf4b 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -4820,7 +4820,15 @@ def _resolve_derived_type_policy( if exact is not None: return exact if semantic_type.metadata.get(models.EXTERNAL_TYPE_REF_METADATA) is not None: - return None + # An imported reference names the type the way the module declaring it + # writes it, which is its own name rather than the native type it binds. + # The search stays inside that module, so a type of the same name + # declared elsewhere is never reached. + scope, name = requested_identity + imported_matches = tuple( + policy for policy in derived_types.values() if policy.native_scope == scope and policy.type_name == name + ) + return imported_matches[0] if len(imported_matches) == 1 else None local_matches = tuple(policy for policy in derived_types.values() if policy.type_name == semantic_type.name) return local_matches[0] if len(local_matches) == 1 else None diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index f7cf1f2c7..e98a889c1 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -451,6 +451,7 @@ def class_def( visibility: str, native_abi: str | None = None, abstract: bool = False, + native_name: str | None = None, ) -> SemanticClass: """Convert one class AST node, its body, and supported native metadata. @@ -481,7 +482,9 @@ def class_def( metadata["fortran_bind_c"] = True semantic_class = SemanticClass( name=node.name, - native_name=node.name, + # A class names its Python type; `bind` states the native type it + # reaches when the two are spelled differently. + native_name=native_name or node.name, fields=body.fields, methods=body.methods, destructors=body.destructors, @@ -822,8 +825,6 @@ def ann_assign( """ name = self.annotation_target(node.target) visibility, semantic_type, original_name = self.visible_type(node.annotation) - if original_name is not None: - name = original_name self._validate_python_value_policy( semantic_type, writable=self._type_uses_writable_storage(semantic_type), @@ -835,6 +836,11 @@ def ann_assign( visibility=visibility, default_value=self.assignment_default_value(node.value, semantic_type), ) + if original_name is not None: + # A declared name is what Python calls this entity; `SourceName` + # states the entity it reaches, exactly as `bind` does for a + # callable, and leaves the declared name alone. + binding.origin.native_name = original_name if visibility == "private": binding.origin.metadata[USER_PRIVATE_METADATA] = True binding.optional = self.default_marks_optional(node.value) @@ -1981,6 +1987,18 @@ def semantic_type_annotation( ) semantic_type.metadata[OPTIONAL_ABSENT_HANDLE_METADATA] = True return semantic_type, None + if self.is_subscript_of(node, "Final"): + # `Final` marks the value immutable and wraps the annotation that + # carries any source name, which the declaration still needs. + items = self.subscript_items(node) + if len(items) == 1: + semantic_type, original_name = self.semantic_type_annotation( + items[0], + allow_optional_absent_handle=allow_optional_absent_handle, + ) + if not any(constraint.name == "Constant" for constraint in semantic_type.constraints): + semantic_type.constraints.append(SemanticConstraint("Constant")) + return semantic_type, original_name if not self.is_subscript_of(node, "Annotated"): return self.semantic_type(node), None @@ -3687,7 +3705,6 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None: decorators = self.parser.decorators(node.decorator_list, context="class body") if ( decorators.has_native_call - or decorators.bind_target is not None or decorators.overload_target is not None or decorators.is_static or decorators.release_gil @@ -3711,6 +3728,7 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None: visibility=decorators.visibility, native_abi=decorators.native_abi, abstract=decorators.abstract, + native_name=decorators.bind_target, ) ) @@ -3755,7 +3773,6 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None: decorators = self.parser.decorators(node.decorator_list, context="class") if ( decorators.has_native_call - or decorators.bind_target is not None or decorators.overload_target is not None or decorators.is_static or decorators.release_gil @@ -3777,6 +3794,7 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None: visibility=decorators.visibility, native_abi=decorators.native_abi, abstract=decorators.abstract, + native_name=decorators.bind_target, ) ) diff --git a/tests/fortran/infrastructure/semantic_pyi/parsing/test_python_ast_contracts.py b/tests/fortran/infrastructure/semantic_pyi/parsing/test_python_ast_contracts.py index 7700ef28a..2398a87eb 100644 --- a/tests/fortran/infrastructure/semantic_pyi/parsing/test_python_ast_contracts.py +++ b/tests/fortran/infrastructure/semantic_pyi/parsing/test_python_ast_contracts.py @@ -79,7 +79,8 @@ def test_pyi_parser_preserves_generic_constraints_as_annotation_metadata(): SemanticConstraint("Bounded", [1, 8]), SemanticConstraint("Finite"), ] - assert module.variables[1].name == "native_alias" + assert module.variables[1].name == "alias" + assert module.variables[1].origin.native_name == "native_alias" assert module.variables[1].semantic_type.constraints == [SemanticConstraint("Finite")] emitted = emit_module(SemanticModule(name="constraints", variables=[module.variables[0]])) assert "value: Annotated[Int32, Bounded(1, 8), Finite]" in emitted diff --git a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py index 4d66a794b..b002003de 100644 --- a/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py +++ b/tests/fortran/infrastructure/semantic_pyi/semantics/test_types_and_values.py @@ -194,7 +194,10 @@ def f() -> tuple[F64, Gives["y", F64]]: ... module_name="edited", ) - assert module.variables[0].name == "native_alias" + # The declared name stays the Python name; SourceName states the native + # entity it reaches, as bind does for a callable. + assert module.variables[0].name == "alias" + assert module.variables[0].origin.native_name == "native_alias" assert module.variables[0].semantic_type.shape == ["1:n"] assert module.functions[0].return_type is not None assert module.functions[0].return_type.name == "Float64" @@ -595,3 +598,44 @@ def test_native_contract_structurally_accepts_declared_type_and_constraint_edits assert native_contract_issues(parse_pyi_text(constrained, module_name="solver_mod")) == [] assert native_contract_issues(parse_pyi_text(changed_abi, module_name="solver_mod")) == [] + + +def test_source_name_binds_a_native_entity_without_taking_the_declared_name(): + """`SourceName` states what a declaration reaches, like `bind` on a callable. + + A contract is edited to give an entity the name Python should call it, and + that name has to survive. Reading the source spelling as the declaration's + own name discards the edit and exports the native spelling instead. + """ + module = pyi_text_to_semantic_module( + """ +from prik.contracts import Annotated, Final, Int32, SourceName + +tally: Annotated[Int32, SourceName("COUNTER")] + +limit: Final[Annotated[Int32, SourceName("MAXFUN")]] +""", + module_name="edited", + ) + + assert [(item.name, item.origin.native_name) for item in module.variables] == [ + ("tally", "COUNTER"), + ("limit", "MAXFUN"), + ] + assert [constraint.name for constraint in module.variables[1].semantic_type.constraints] == ["Constant"] + + +def test_class_binds_a_native_type_under_its_own_python_name(): + """A class states the native type it reaches when the two names differ.""" + module = pyi_text_to_semantic_module( + """ +from prik.contracts import Float64, bind + +@bind("POINT_T") +class PointType: + x: Float64 +""", + module_name="edited", + ) + + assert (module.classes[0].name, module.classes[0].native_name) == ("PointType", "POINT_T") From fc4a32d370e7beb951da91a60581549171c3a2e1 Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 14:14:02 +0100 Subject: [PATCH 23/24] Record a source spelling only where Python cannot hold the name Fortran names entities without regard to case, so writing a capitalized `IK` as `ik` renames nothing -- the generated Fortran reaches it either way. Every such declaration nevertheless carried a `SourceName` or `@bind` stating the capitals back, which said nothing the declaration did not already say. Across one real library's contracts that was 60 annotations, none of them load-bearing. The naming policy already drew this line: `normalize_public_name` reports `needs_fix` against the casefolded source, so a pure case change is deliberately not a fix. The printer compared the spellings exactly instead and never consulted it. A name Python cannot hold as written keeps its original: a keyword, a character an identifier cannot carry, a name a collision moved aside. So does every name from a source language that is case-sensitive, where the spellings are still compared as written. A renamed class now states its native type, so the rename survives regeneration. A C struct keeps its own representation rules, which spell `struct node` without a decorator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 + prik/printers/pyi.py | 39 +++- .../contracts/fmath_arrays/__init__.pyi | 87 +-------- .../fmath_arrays_f90/fmath_arrays_f90.pyi | 172 +----------------- .../fixtures/contracts/fmath/__init__.pyi | 87 +-------- .../contracts/fmath_f90/fmath_f90.pyi | 87 +-------- .../policy/test_wrapper_policy.py | 4 +- .../test_pyi_printer_imports_and_packages.py | 119 +++++++++++- .../pipeline/test_types_and_declarations.py | 10 +- .../fixtures/contracts/fstrings/__init__.pyi | 11 +- 10 files changed, 177 insertions(+), 448 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b995c216f..0c7ba399d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ release tags add a leading `v` to the package version. reference resolves through the name its declaring contract states, and a renamed class keeps its `bind` when the contract is regenerated. +- A generated Fortran contract no longer records a source spelling that differs + from its Python name only by case. Fortran names entities without regard to + case, so a capitalized `IK` written as `ik` renames nothing and the generated + Fortran reaches it either way; every such declaration nevertheless carried a + `SourceName` or `@bind` stating the capitals back. A name Python cannot hold + as written -- a keyword, an illegal character, one a collision moved aside -- + is a real rename and still keeps its original, as does every name from a + source language that is case-sensitive. + - A generated contract now imports each name under the spelling the contract that defines it uses. A source-derived contract declares a Fortran entity under a Python name, so one spelled in capitals is declared lower case, while diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 7a966a4cc..51c300cef 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -451,6 +451,15 @@ def _visit_SemanticClass( decorators.append(f"@{context.contract('abstract')}") if self._class_uses_c_abi(cls): decorators.append(f'@{context.contract("native_abi")}("c")') + # Only a Fortran type states a separate native name here. A C struct + # keeps its native spelling -- `struct node` for `node` -- through its + # own representation rules, which state it without a decorator. + if ( + cls.origin.source_language == "fortran" + and cls.native_name + and self._renames_native_entity(cls, cls.native_name, cls.name) + ): + decorators.append(f"@{context.contract('bind')}({json.dumps(str(cls.native_name))})") decorator_text = "\n".join(decorators) if decorator_text: decorator_text += "\n" @@ -954,7 +963,7 @@ def _emit_data_member( self._annotation_target(name), variable, context, - original_name=variable.name if name != variable.name else None, + original_name=variable.name if self._renames_native_entity(variable, variable.name, name) else None, ) def _emit_module_variable( @@ -968,7 +977,7 @@ def _emit_module_variable( self._annotation_target(name), arg, context, - original_name=arg.name if name != arg.name else None, + original_name=arg.name if self._renames_native_entity(arg, arg.name, name) else None, ) @staticmethod @@ -1345,7 +1354,7 @@ def _constructor_argument( or self._python_literal_text(field.default_value) or "..." ) - if name != field.name: + if self._renames_native_entity(field, field.name, name): type_text = self._annotated_type_text( type_text, [f"{context.contract('SourceName')}({json.dumps(field.name)})"], @@ -2230,13 +2239,13 @@ def _bind_target( if bind_target is not None: return bind_target - if isinstance(func, SemanticMethod) and func.name != emitted_name: + if isinstance(func, SemanticMethod) and PyiPrinter._renames_native_entity(func, func.name, emitted_name): if not context.public_namespace: return func.native_name class_name = context.public_namespace[-1] return f"{class_name}.{func.name}" - if func.native_name and func.native_name != emitted_name: + if func.native_name and PyiPrinter._renames_native_entity(func, func.native_name, emitted_name): return func.native_name return None @@ -2646,6 +2655,26 @@ def _is_private(node) -> bool: """Return whether is private.""" return getattr(node, "visibility", "public") == "private" + @staticmethod + def _renames_native_entity(declaration: object, native_name: object, emitted_name: str) -> bool: + """Return whether an emitted name has to record the spelling it came from. + + A Fortran entity is named without regard to case, so writing one under a + lower-case Python name renames nothing and states nothing worth + recording. Any other difference is a real rename -- a Python keyword, a + character an identifier cannot hold, a name a collision moved aside -- + and the declaration keeps the original beside it. Every other source + language names its entities exactly, so there the spellings are compared + as written. + """ + native = str(native_name) + if native == emitted_name: + return False + origin = getattr(declaration, "origin", None) + if getattr(origin, "source_language", None) != "fortran": + return True + return native.casefold() != emitted_name.casefold() + @staticmethod def _annotation_target(name: str) -> str: """Handle annotation target for the current generation context.""" diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays/__init__.pyi index 1746077b5..876fd52cc 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays/__init__.pyi @@ -1,6 +1,5 @@ -from prik.contracts import Addr, Arg, Bool8, Complex128, Complex64, Float32, Float64, Int32, Returns, bind, native_call, standalone +from prik.contracts import Addr, Arg, Bool8, Complex128, Complex64, Float32, Float64, Int32, Returns, native_call, standalone -@bind("SQUARE_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r4( @@ -9,7 +8,6 @@ def square_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("SQUARE_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r8( @@ -18,7 +16,6 @@ def square_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("SQUARE_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_i4( @@ -27,7 +24,6 @@ def square_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c4( @@ -36,7 +32,6 @@ def square_c4( R: Complex64[N] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c8( @@ -45,7 +40,6 @@ def square_c8( R: Complex128[N] ) -> Returns["N", Int32]: ... -@bind("CUBE_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r4( @@ -54,7 +48,6 @@ def cube_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("CUBE_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r8( @@ -63,7 +56,6 @@ def cube_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("CUBE_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_i4( @@ -72,7 +64,6 @@ def cube_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("ADD_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r4( @@ -82,7 +73,6 @@ def add_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ADD_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r8( @@ -92,7 +82,6 @@ def add_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ADD_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_i4( @@ -102,7 +91,6 @@ def add_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("ADD_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c4( @@ -112,7 +100,6 @@ def add_c4( R: Complex64[N] ) -> Returns["N", Int32]: ... -@bind("ADD_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c8( @@ -122,7 +109,6 @@ def add_c8( R: Complex128[N] ) -> Returns["N", Int32]: ... -@bind("SUB_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r4( @@ -132,7 +118,6 @@ def sub_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("SUB_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r8( @@ -142,7 +127,6 @@ def sub_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("SUB_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_i4( @@ -152,7 +136,6 @@ def sub_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("MUL_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r4( @@ -162,7 +145,6 @@ def mul_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("MUL_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r8( @@ -172,7 +154,6 @@ def mul_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("MUL_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_i4( @@ -182,7 +163,6 @@ def mul_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("DIV_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r4( @@ -192,7 +172,6 @@ def div_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("DIV_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r8( @@ -202,7 +181,6 @@ def div_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("POW_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r4( @@ -212,7 +190,6 @@ def pow_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("POW_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r8( @@ -222,7 +199,6 @@ def pow_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ABS_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r4( @@ -231,7 +207,6 @@ def abs_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ABS_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r8( @@ -240,7 +215,6 @@ def abs_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ABS_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_i4( @@ -249,7 +223,6 @@ def abs_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("NEG_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r4( @@ -258,7 +231,6 @@ def neg_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("NEG_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r8( @@ -267,7 +239,6 @@ def neg_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("NEG_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_i4( @@ -276,7 +247,6 @@ def neg_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("SIN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r4( @@ -285,7 +255,6 @@ def sin_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("SIN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r8( @@ -294,7 +263,6 @@ def sin_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("COS_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r4( @@ -303,7 +271,6 @@ def cos_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("COS_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r8( @@ -312,7 +279,6 @@ def cos_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("TAN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r4( @@ -321,7 +287,6 @@ def tan_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("TAN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r8( @@ -330,7 +295,6 @@ def tan_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ASIN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r4( @@ -339,7 +303,6 @@ def asin_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ASIN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r8( @@ -348,7 +311,6 @@ def asin_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ACOS_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r4( @@ -357,7 +319,6 @@ def acos_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ACOS_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r8( @@ -366,7 +327,6 @@ def acos_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ATAN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r4( @@ -375,7 +335,6 @@ def atan_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ATAN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r8( @@ -384,7 +343,6 @@ def atan_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r4( @@ -394,7 +352,6 @@ def atan2_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r8( @@ -404,7 +361,6 @@ def atan2_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("EXP_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r4( @@ -413,7 +369,6 @@ def exp_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("EXP_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r8( @@ -422,7 +377,6 @@ def exp_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("LOG_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r4( @@ -431,7 +385,6 @@ def log_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("LOG_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r8( @@ -440,7 +393,6 @@ def log_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("LOG10_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r4( @@ -449,7 +401,6 @@ def log10_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("LOG10_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r8( @@ -458,7 +409,6 @@ def log10_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("SQRT_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r4( @@ -467,7 +417,6 @@ def sqrt_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("SQRT_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r8( @@ -476,7 +425,6 @@ def sqrt_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r4( @@ -486,7 +434,6 @@ def hypot_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r8( @@ -496,7 +443,6 @@ def hypot_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("MIN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r4( @@ -506,7 +452,6 @@ def min_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("MIN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r8( @@ -516,7 +461,6 @@ def min_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("MIN_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_i4( @@ -526,7 +470,6 @@ def min_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("MAX_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r4( @@ -536,7 +479,6 @@ def max_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("MAX_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r8( @@ -546,7 +488,6 @@ def max_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("MAX_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_i4( @@ -556,7 +497,6 @@ def max_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("SIGN_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r4( @@ -566,7 +506,6 @@ def sign_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("SIGN_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r8( @@ -576,7 +515,6 @@ def sign_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("MOD_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_i4( @@ -586,7 +524,6 @@ def mod_i4( R: Int32[N] ) -> Returns["N", Int32]: ... -@bind("MOD_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r4( @@ -596,7 +533,6 @@ def mod_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("MOD_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r8( @@ -606,7 +542,6 @@ def mod_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r4( @@ -615,7 +550,6 @@ def deg2rad_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r8( @@ -624,7 +558,6 @@ def deg2rad_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r4( @@ -633,7 +566,6 @@ def rad2deg_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r8( @@ -642,7 +574,6 @@ def rad2deg_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("DIST2_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r4( @@ -652,7 +583,6 @@ def dist2_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("DIST2_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r8( @@ -662,7 +592,6 @@ def dist2_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("DOT2_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r4( @@ -674,7 +603,6 @@ def dot2_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("DOT2_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r8( @@ -686,7 +614,6 @@ def dot2_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("DOT3_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r4( @@ -700,7 +627,6 @@ def dot3_r4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("DOT3_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r8( @@ -714,7 +640,6 @@ def dot3_r8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("CONJ_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c4( @@ -723,7 +648,6 @@ def conj_c4( R: Complex64[N] ) -> Returns["N", Int32]: ... -@bind("CONJ_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c8( @@ -732,7 +656,6 @@ def conj_c8( R: Complex128[N] ) -> Returns["N", Int32]: ... -@bind("REAL_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c4( @@ -741,7 +664,6 @@ def real_c4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("REAL_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c8( @@ -750,7 +672,6 @@ def real_c8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c4( @@ -759,7 +680,6 @@ def aimag_c4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c8( @@ -768,7 +688,6 @@ def aimag_c8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("ABS_C4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c4( @@ -777,7 +696,6 @@ def abs_c4( R: Float32[N] ) -> Returns["N", Int32]: ... -@bind("ABS_C8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c8( @@ -786,7 +704,6 @@ def abs_c8( R: Float64[N] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r4( @@ -795,7 +712,6 @@ def is_positive_r4( R: Bool8[N] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R8") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r8( @@ -804,7 +720,6 @@ def is_positive_r8( R: Bool8[N] ) -> Returns["N", Int32]: ... -@bind("IS_EVEN_I4") @standalone @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_even_i4( diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/fmath_arrays_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/fmath_arrays_f90.pyi index 2c5a7a922..74a31ac5e 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/fmath_arrays_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/fmath_arrays_f90.pyi @@ -1,6 +1,5 @@ -from prik.contracts import Addr, Arg, Bool8, Complex128, Complex64, Float32, Float64, Int32, Returns, bind, native_call +from prik.contracts import Addr, Arg, Bool8, Complex128, Complex64, Float32, Float64, Int32, Returns, native_call -@bind("SQUARE_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r4_contiguous( N: Int32, @@ -8,7 +7,6 @@ def square_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("SQUARE_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r8_contiguous( N: Int32, @@ -16,7 +14,6 @@ def square_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("SQUARE_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_i4_contiguous( N: Int32, @@ -24,7 +21,6 @@ def square_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c4_contiguous( N: Int32, @@ -32,7 +28,6 @@ def square_c4_contiguous( R: Complex64[:] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c8_contiguous( N: Int32, @@ -40,7 +35,6 @@ def square_c8_contiguous( R: Complex128[:] ) -> Returns["N", Int32]: ... -@bind("CUBE_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r4_contiguous( N: Int32, @@ -48,7 +42,6 @@ def cube_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("CUBE_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r8_contiguous( N: Int32, @@ -56,7 +49,6 @@ def cube_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("CUBE_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_i4_contiguous( N: Int32, @@ -64,7 +56,6 @@ def cube_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("ADD_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r4_contiguous( N: Int32, @@ -73,7 +64,6 @@ def add_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ADD_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r8_contiguous( N: Int32, @@ -82,7 +72,6 @@ def add_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ADD_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_i4_contiguous( N: Int32, @@ -91,7 +80,6 @@ def add_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("ADD_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c4_contiguous( N: Int32, @@ -100,7 +88,6 @@ def add_c4_contiguous( R: Complex64[:] ) -> Returns["N", Int32]: ... -@bind("ADD_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c8_contiguous( N: Int32, @@ -109,7 +96,6 @@ def add_c8_contiguous( R: Complex128[:] ) -> Returns["N", Int32]: ... -@bind("SUB_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r4_contiguous( N: Int32, @@ -118,7 +104,6 @@ def sub_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("SUB_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r8_contiguous( N: Int32, @@ -127,7 +112,6 @@ def sub_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("SUB_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_i4_contiguous( N: Int32, @@ -136,7 +120,6 @@ def sub_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("MUL_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r4_contiguous( N: Int32, @@ -145,7 +128,6 @@ def mul_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("MUL_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r8_contiguous( N: Int32, @@ -154,7 +136,6 @@ def mul_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("MUL_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_i4_contiguous( N: Int32, @@ -163,7 +144,6 @@ def mul_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("DIV_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r4_contiguous( N: Int32, @@ -172,7 +152,6 @@ def div_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("DIV_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r8_contiguous( N: Int32, @@ -181,7 +160,6 @@ def div_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("POW_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r4_contiguous( N: Int32, @@ -190,7 +168,6 @@ def pow_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("POW_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r8_contiguous( N: Int32, @@ -199,7 +176,6 @@ def pow_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ABS_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r4_contiguous( N: Int32, @@ -207,7 +183,6 @@ def abs_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ABS_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r8_contiguous( N: Int32, @@ -215,7 +190,6 @@ def abs_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ABS_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_i4_contiguous( N: Int32, @@ -223,7 +197,6 @@ def abs_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("NEG_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r4_contiguous( N: Int32, @@ -231,7 +204,6 @@ def neg_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("NEG_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r8_contiguous( N: Int32, @@ -239,7 +211,6 @@ def neg_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("NEG_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_i4_contiguous( N: Int32, @@ -247,7 +218,6 @@ def neg_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("SIN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r4_contiguous( N: Int32, @@ -255,7 +225,6 @@ def sin_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("SIN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r8_contiguous( N: Int32, @@ -263,7 +232,6 @@ def sin_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("COS_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r4_contiguous( N: Int32, @@ -271,7 +239,6 @@ def cos_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("COS_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r8_contiguous( N: Int32, @@ -279,7 +246,6 @@ def cos_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("TAN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r4_contiguous( N: Int32, @@ -287,7 +253,6 @@ def tan_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("TAN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r8_contiguous( N: Int32, @@ -295,7 +260,6 @@ def tan_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ASIN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r4_contiguous( N: Int32, @@ -303,7 +267,6 @@ def asin_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ASIN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r8_contiguous( N: Int32, @@ -311,7 +274,6 @@ def asin_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ACOS_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r4_contiguous( N: Int32, @@ -319,7 +281,6 @@ def acos_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ACOS_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r8_contiguous( N: Int32, @@ -327,7 +288,6 @@ def acos_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ATAN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r4_contiguous( N: Int32, @@ -335,7 +295,6 @@ def atan_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ATAN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r8_contiguous( N: Int32, @@ -343,7 +302,6 @@ def atan_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r4_contiguous( N: Int32, @@ -352,7 +310,6 @@ def atan2_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r8_contiguous( N: Int32, @@ -361,7 +318,6 @@ def atan2_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("EXP_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r4_contiguous( N: Int32, @@ -369,7 +325,6 @@ def exp_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("EXP_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r8_contiguous( N: Int32, @@ -377,7 +332,6 @@ def exp_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("LOG_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r4_contiguous( N: Int32, @@ -385,7 +339,6 @@ def log_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("LOG_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r8_contiguous( N: Int32, @@ -393,7 +346,6 @@ def log_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("LOG10_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r4_contiguous( N: Int32, @@ -401,7 +353,6 @@ def log10_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("LOG10_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r8_contiguous( N: Int32, @@ -409,7 +360,6 @@ def log10_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("SQRT_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r4_contiguous( N: Int32, @@ -417,7 +367,6 @@ def sqrt_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("SQRT_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r8_contiguous( N: Int32, @@ -425,7 +374,6 @@ def sqrt_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r4_contiguous( N: Int32, @@ -434,7 +382,6 @@ def hypot_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r8_contiguous( N: Int32, @@ -443,7 +390,6 @@ def hypot_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("MIN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r4_contiguous( N: Int32, @@ -452,7 +398,6 @@ def min_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("MIN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r8_contiguous( N: Int32, @@ -461,7 +406,6 @@ def min_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("MIN_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_i4_contiguous( N: Int32, @@ -470,7 +414,6 @@ def min_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("MAX_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r4_contiguous( N: Int32, @@ -479,7 +422,6 @@ def max_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("MAX_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r8_contiguous( N: Int32, @@ -488,7 +430,6 @@ def max_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("MAX_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_i4_contiguous( N: Int32, @@ -497,7 +438,6 @@ def max_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("SIGN_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r4_contiguous( N: Int32, @@ -506,7 +446,6 @@ def sign_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("SIGN_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r8_contiguous( N: Int32, @@ -515,7 +454,6 @@ def sign_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("MOD_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_i4_contiguous( N: Int32, @@ -524,7 +462,6 @@ def mod_i4_contiguous( R: Int32[:] ) -> Returns["N", Int32]: ... -@bind("MOD_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r4_contiguous( N: Int32, @@ -533,7 +470,6 @@ def mod_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("MOD_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r8_contiguous( N: Int32, @@ -542,7 +478,6 @@ def mod_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r4_contiguous( N: Int32, @@ -550,7 +485,6 @@ def deg2rad_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r8_contiguous( N: Int32, @@ -558,7 +492,6 @@ def deg2rad_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r4_contiguous( N: Int32, @@ -566,7 +499,6 @@ def rad2deg_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r8_contiguous( N: Int32, @@ -574,7 +506,6 @@ def rad2deg_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("DIST2_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r4_contiguous( N: Int32, @@ -583,7 +514,6 @@ def dist2_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("DIST2_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r8_contiguous( N: Int32, @@ -592,7 +522,6 @@ def dist2_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("DOT2_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r4_contiguous( N: Int32, @@ -603,7 +532,6 @@ def dot2_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("DOT2_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r8_contiguous( N: Int32, @@ -614,7 +542,6 @@ def dot2_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("DOT3_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r4_contiguous( N: Int32, @@ -627,7 +554,6 @@ def dot3_r4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("DOT3_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r8_contiguous( N: Int32, @@ -640,7 +566,6 @@ def dot3_r8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("CONJ_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c4_contiguous( N: Int32, @@ -648,7 +573,6 @@ def conj_c4_contiguous( R: Complex64[:] ) -> Returns["N", Int32]: ... -@bind("CONJ_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c8_contiguous( N: Int32, @@ -656,7 +580,6 @@ def conj_c8_contiguous( R: Complex128[:] ) -> Returns["N", Int32]: ... -@bind("REAL_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c4_contiguous( N: Int32, @@ -664,7 +587,6 @@ def real_c4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("REAL_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c8_contiguous( N: Int32, @@ -672,7 +594,6 @@ def real_c8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c4_contiguous( N: Int32, @@ -680,7 +601,6 @@ def aimag_c4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c8_contiguous( N: Int32, @@ -688,7 +608,6 @@ def aimag_c8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("ABS_C4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c4_contiguous( N: Int32, @@ -696,7 +615,6 @@ def abs_c4_contiguous( R: Float32[:] ) -> Returns["N", Int32]: ... -@bind("ABS_C8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c8_contiguous( N: Int32, @@ -704,7 +622,6 @@ def abs_c8_contiguous( R: Float64[:] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r4_contiguous( N: Int32, @@ -712,7 +629,6 @@ def is_positive_r4_contiguous( R: Bool8[:] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R8_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r8_contiguous( N: Int32, @@ -720,7 +636,6 @@ def is_positive_r8_contiguous( R: Bool8[:] ) -> Returns["N", Int32]: ... -@bind("IS_EVEN_I4_CONTIGUOUS") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_even_i4_contiguous( N: Int32, @@ -728,7 +643,6 @@ def is_even_i4_contiguous( R: Bool8[:] ) -> Returns["N", Int32]: ... -@bind("SQUARE_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r4_strided( N: Int32, @@ -736,7 +650,6 @@ def square_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("SQUARE_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_r8_strided( N: Int32, @@ -744,7 +657,6 @@ def square_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("SQUARE_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_i4_strided( N: Int32, @@ -752,7 +664,6 @@ def square_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c4_strided( N: Int32, @@ -760,7 +671,6 @@ def square_c4_strided( R: Complex64[::] ) -> Returns["N", Int32]: ... -@bind("SQUARE_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def square_c8_strided( N: Int32, @@ -768,7 +678,6 @@ def square_c8_strided( R: Complex128[::] ) -> Returns["N", Int32]: ... -@bind("CUBE_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r4_strided( N: Int32, @@ -776,7 +685,6 @@ def cube_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("CUBE_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_r8_strided( N: Int32, @@ -784,7 +692,6 @@ def cube_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("CUBE_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cube_i4_strided( N: Int32, @@ -792,7 +699,6 @@ def cube_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("ADD_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r4_strided( N: Int32, @@ -801,7 +707,6 @@ def add_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ADD_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_r8_strided( N: Int32, @@ -810,7 +715,6 @@ def add_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ADD_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_i4_strided( N: Int32, @@ -819,7 +723,6 @@ def add_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("ADD_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c4_strided( N: Int32, @@ -828,7 +731,6 @@ def add_c4_strided( R: Complex64[::] ) -> Returns["N", Int32]: ... -@bind("ADD_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def add_c8_strided( N: Int32, @@ -837,7 +739,6 @@ def add_c8_strided( R: Complex128[::] ) -> Returns["N", Int32]: ... -@bind("SUB_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r4_strided( N: Int32, @@ -846,7 +747,6 @@ def sub_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("SUB_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_r8_strided( N: Int32, @@ -855,7 +755,6 @@ def sub_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("SUB_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sub_i4_strided( N: Int32, @@ -864,7 +763,6 @@ def sub_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("MUL_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r4_strided( N: Int32, @@ -873,7 +771,6 @@ def mul_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("MUL_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_r8_strided( N: Int32, @@ -882,7 +779,6 @@ def mul_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("MUL_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mul_i4_strided( N: Int32, @@ -891,7 +787,6 @@ def mul_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("DIV_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r4_strided( N: Int32, @@ -900,7 +795,6 @@ def div_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("DIV_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def div_r8_strided( N: Int32, @@ -909,7 +803,6 @@ def div_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("POW_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r4_strided( N: Int32, @@ -918,7 +811,6 @@ def pow_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("POW_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def pow_r8_strided( N: Int32, @@ -927,7 +819,6 @@ def pow_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ABS_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r4_strided( N: Int32, @@ -935,7 +826,6 @@ def abs_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ABS_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_r8_strided( N: Int32, @@ -943,7 +833,6 @@ def abs_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ABS_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_i4_strided( N: Int32, @@ -951,7 +840,6 @@ def abs_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("NEG_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r4_strided( N: Int32, @@ -959,7 +847,6 @@ def neg_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("NEG_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_r8_strided( N: Int32, @@ -967,7 +854,6 @@ def neg_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("NEG_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def neg_i4_strided( N: Int32, @@ -975,7 +861,6 @@ def neg_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("SIN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r4_strided( N: Int32, @@ -983,7 +868,6 @@ def sin_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("SIN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sin_r8_strided( N: Int32, @@ -991,7 +875,6 @@ def sin_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("COS_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r4_strided( N: Int32, @@ -999,7 +882,6 @@ def cos_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("COS_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def cos_r8_strided( N: Int32, @@ -1007,7 +889,6 @@ def cos_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("TAN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r4_strided( N: Int32, @@ -1015,7 +896,6 @@ def tan_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("TAN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def tan_r8_strided( N: Int32, @@ -1023,7 +903,6 @@ def tan_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ASIN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r4_strided( N: Int32, @@ -1031,7 +910,6 @@ def asin_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ASIN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def asin_r8_strided( N: Int32, @@ -1039,7 +917,6 @@ def asin_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ACOS_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r4_strided( N: Int32, @@ -1047,7 +924,6 @@ def acos_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ACOS_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def acos_r8_strided( N: Int32, @@ -1055,7 +931,6 @@ def acos_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ATAN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r4_strided( N: Int32, @@ -1063,7 +938,6 @@ def atan_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ATAN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def atan_r8_strided( N: Int32, @@ -1071,7 +945,6 @@ def atan_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r4_strided( N: Int32, @@ -1080,7 +953,6 @@ def atan2_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ATAN2_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def atan2_r8_strided( N: Int32, @@ -1089,7 +961,6 @@ def atan2_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("EXP_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r4_strided( N: Int32, @@ -1097,7 +968,6 @@ def exp_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("EXP_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def exp_r8_strided( N: Int32, @@ -1105,7 +975,6 @@ def exp_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("LOG_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r4_strided( N: Int32, @@ -1113,7 +982,6 @@ def log_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("LOG_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log_r8_strided( N: Int32, @@ -1121,7 +989,6 @@ def log_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("LOG10_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r4_strided( N: Int32, @@ -1129,7 +996,6 @@ def log10_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("LOG10_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def log10_r8_strided( N: Int32, @@ -1137,7 +1003,6 @@ def log10_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("SQRT_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r4_strided( N: Int32, @@ -1145,7 +1010,6 @@ def sqrt_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("SQRT_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def sqrt_r8_strided( N: Int32, @@ -1153,7 +1017,6 @@ def sqrt_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r4_strided( N: Int32, @@ -1162,7 +1025,6 @@ def hypot_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("HYPOT_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def hypot_r8_strided( N: Int32, @@ -1171,7 +1033,6 @@ def hypot_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("MIN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r4_strided( N: Int32, @@ -1180,7 +1041,6 @@ def min_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("MIN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_r8_strided( N: Int32, @@ -1189,7 +1049,6 @@ def min_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("MIN_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def min_i4_strided( N: Int32, @@ -1198,7 +1057,6 @@ def min_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("MAX_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r4_strided( N: Int32, @@ -1207,7 +1065,6 @@ def max_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("MAX_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_r8_strided( N: Int32, @@ -1216,7 +1073,6 @@ def max_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("MAX_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def max_i4_strided( N: Int32, @@ -1225,7 +1081,6 @@ def max_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("SIGN_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r4_strided( N: Int32, @@ -1234,7 +1089,6 @@ def sign_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("SIGN_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def sign_r8_strided( N: Int32, @@ -1243,7 +1097,6 @@ def sign_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("MOD_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_i4_strided( N: Int32, @@ -1252,7 +1105,6 @@ def mod_i4_strided( R: Int32[::] ) -> Returns["N", Int32]: ... -@bind("MOD_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r4_strided( N: Int32, @@ -1261,7 +1113,6 @@ def mod_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("MOD_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def mod_r8_strided( N: Int32, @@ -1270,7 +1121,6 @@ def mod_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r4_strided( N: Int32, @@ -1278,7 +1128,6 @@ def deg2rad_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("DEG2RAD_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def deg2rad_r8_strided( N: Int32, @@ -1286,7 +1135,6 @@ def deg2rad_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r4_strided( N: Int32, @@ -1294,7 +1142,6 @@ def rad2deg_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("RAD2DEG_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def rad2deg_r8_strided( N: Int32, @@ -1302,7 +1149,6 @@ def rad2deg_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("DIST2_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r4_strided( N: Int32, @@ -1311,7 +1157,6 @@ def dist2_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("DIST2_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3)]) def dist2_r8_strided( N: Int32, @@ -1320,7 +1165,6 @@ def dist2_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("DOT2_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r4_strided( N: Int32, @@ -1331,7 +1175,6 @@ def dot2_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("DOT2_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5)]) def dot2_r8_strided( N: Int32, @@ -1342,7 +1185,6 @@ def dot2_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("DOT3_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r4_strided( N: Int32, @@ -1355,7 +1197,6 @@ def dot3_r4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("DOT3_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2), Arg(3), Arg(4), Arg(5), Arg(6), Arg(7)]) def dot3_r8_strided( N: Int32, @@ -1368,7 +1209,6 @@ def dot3_r8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("CONJ_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c4_strided( N: Int32, @@ -1376,7 +1216,6 @@ def conj_c4_strided( R: Complex64[::] ) -> Returns["N", Int32]: ... -@bind("CONJ_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def conj_c8_strided( N: Int32, @@ -1384,7 +1223,6 @@ def conj_c8_strided( R: Complex128[::] ) -> Returns["N", Int32]: ... -@bind("REAL_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c4_strided( N: Int32, @@ -1392,7 +1230,6 @@ def real_c4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("REAL_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def real_c8_strided( N: Int32, @@ -1400,7 +1237,6 @@ def real_c8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c4_strided( N: Int32, @@ -1408,7 +1244,6 @@ def aimag_c4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("AIMAG_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def aimag_c8_strided( N: Int32, @@ -1416,7 +1251,6 @@ def aimag_c8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("ABS_C4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c4_strided( N: Int32, @@ -1424,7 +1258,6 @@ def abs_c4_strided( R: Float32[::] ) -> Returns["N", Int32]: ... -@bind("ABS_C8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def abs_c8_strided( N: Int32, @@ -1432,7 +1265,6 @@ def abs_c8_strided( R: Float64[::] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r4_strided( N: Int32, @@ -1440,7 +1272,6 @@ def is_positive_r4_strided( R: Bool8[::] ) -> Returns["N", Int32]: ... -@bind("IS_POSITIVE_R8_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_positive_r8_strided( N: Int32, @@ -1448,7 +1279,6 @@ def is_positive_r8_strided( R: Bool8[::] ) -> Returns["N", Int32]: ... -@bind("IS_EVEN_I4_STRIDED") @native_call([Addr(Arg(0)), Arg(1), Arg(2)]) def is_even_i4_strided( N: Int32, diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath/__init__.pyi index d7a7d8642..26cc2cc80 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath/__init__.pyi @@ -1,62 +1,53 @@ -from prik.contracts import Addr, Arg, Bool32, Complex128, Complex64, Float32, Float64, Int32, Returns, bind, native_call, standalone +from prik.contracts import Addr, Arg, Bool32, Complex128, Complex64, Float32, Float64, Int32, Returns, native_call, standalone -@bind("SQUARE_R4") @standalone @native_call([Addr(Arg(0))]) def square_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SQUARE_R8") @standalone @native_call([Addr(Arg(0))]) def square_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("SQUARE_I4") @standalone @native_call([Addr(Arg(0))]) def square_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("SQUARE_C4") @standalone @native_call([Addr(Arg(0))]) def square_c4( Z: Complex64 ) -> tuple[Complex64, Returns["Z", Complex64]]: ... -@bind("SQUARE_C8") @standalone @native_call([Addr(Arg(0))]) def square_c8( Z: Complex128 ) -> tuple[Complex128, Returns["Z", Complex128]]: ... -@bind("CUBE_R4") @standalone @native_call([Addr(Arg(0))]) def cube_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("CUBE_R8") @standalone @native_call([Addr(Arg(0))]) def cube_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("CUBE_I4") @standalone @native_call([Addr(Arg(0))]) def cube_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("ADD_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_r4( @@ -64,7 +55,6 @@ def add_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("ADD_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_r8( @@ -72,7 +62,6 @@ def add_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("ADD_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_i4( @@ -80,7 +69,6 @@ def add_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("ADD_C4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_c4( @@ -88,7 +76,6 @@ def add_c4( Y: Complex64 ) -> tuple[Complex64, Returns["X", Complex64], Returns["Y", Complex64]]: ... -@bind("ADD_C8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_c8( @@ -96,7 +83,6 @@ def add_c8( Y: Complex128 ) -> tuple[Complex128, Returns["X", Complex128], Returns["Y", Complex128]]: ... -@bind("SUB_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_r4( @@ -104,7 +90,6 @@ def sub_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("SUB_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_r8( @@ -112,7 +97,6 @@ def sub_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("SUB_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_i4( @@ -120,7 +104,6 @@ def sub_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MUL_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_r4( @@ -128,7 +111,6 @@ def mul_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MUL_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_r8( @@ -136,7 +118,6 @@ def mul_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MUL_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_i4( @@ -144,7 +125,6 @@ def mul_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("DIV_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def div_r4( @@ -152,7 +132,6 @@ def div_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("DIV_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def div_r8( @@ -160,7 +139,6 @@ def div_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("POW_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def pow_r4( @@ -168,7 +146,6 @@ def pow_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("POW_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def pow_r8( @@ -176,133 +153,114 @@ def pow_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("ABS_R4") @standalone @native_call([Addr(Arg(0))]) def abs_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ABS_R8") @standalone @native_call([Addr(Arg(0))]) def abs_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ABS_I4") @standalone @native_call([Addr(Arg(0))]) def abs_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("NEG_R4") @standalone @native_call([Addr(Arg(0))]) def neg_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("NEG_R8") @standalone @native_call([Addr(Arg(0))]) def neg_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("NEG_I4") @standalone @native_call([Addr(Arg(0))]) def neg_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("SIN_R4") @standalone @native_call([Addr(Arg(0))]) def sin_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SIN_R8") @standalone @native_call([Addr(Arg(0))]) def sin_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("COS_R4") @standalone @native_call([Addr(Arg(0))]) def cos_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("COS_R8") @standalone @native_call([Addr(Arg(0))]) def cos_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("TAN_R4") @standalone @native_call([Addr(Arg(0))]) def tan_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("TAN_R8") @standalone @native_call([Addr(Arg(0))]) def tan_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ASIN_R4") @standalone @native_call([Addr(Arg(0))]) def asin_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ASIN_R8") @standalone @native_call([Addr(Arg(0))]) def asin_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ACOS_R4") @standalone @native_call([Addr(Arg(0))]) def acos_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ACOS_R8") @standalone @native_call([Addr(Arg(0))]) def acos_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ATAN_R4") @standalone @native_call([Addr(Arg(0))]) def atan_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ATAN_R8") @standalone @native_call([Addr(Arg(0))]) def atan_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ATAN2_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def atan2_r4( @@ -310,7 +268,6 @@ def atan2_r4( X: Float32 ) -> tuple[Float32, Returns["Y", Float32], Returns["X", Float32]]: ... -@bind("ATAN2_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def atan2_r8( @@ -318,63 +275,54 @@ def atan2_r8( X: Float64 ) -> tuple[Float64, Returns["Y", Float64], Returns["X", Float64]]: ... -@bind("EXP_R4") @standalone @native_call([Addr(Arg(0))]) def exp_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("EXP_R8") @standalone @native_call([Addr(Arg(0))]) def exp_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("LOG_R4") @standalone @native_call([Addr(Arg(0))]) def log_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("LOG_R8") @standalone @native_call([Addr(Arg(0))]) def log_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("LOG10_R4") @standalone @native_call([Addr(Arg(0))]) def log10_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("LOG10_R8") @standalone @native_call([Addr(Arg(0))]) def log10_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("SQRT_R4") @standalone @native_call([Addr(Arg(0))]) def sqrt_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SQRT_R8") @standalone @native_call([Addr(Arg(0))]) def sqrt_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("HYPOT_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def hypot_r4( @@ -382,7 +330,6 @@ def hypot_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("HYPOT_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def hypot_r8( @@ -390,7 +337,6 @@ def hypot_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MIN_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_r4( @@ -398,7 +344,6 @@ def min_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MIN_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_r8( @@ -406,7 +351,6 @@ def min_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MIN_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_i4( @@ -414,7 +358,6 @@ def min_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MAX_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_r4( @@ -422,7 +365,6 @@ def max_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MAX_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_r8( @@ -430,7 +372,6 @@ def max_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MAX_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_i4( @@ -438,7 +379,6 @@ def max_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("SIGN_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sign_r4( @@ -446,7 +386,6 @@ def sign_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("SIGN_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sign_r8( @@ -454,7 +393,6 @@ def sign_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MOD_I4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_i4( @@ -462,7 +400,6 @@ def mod_i4( Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MOD_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_r4( @@ -470,7 +407,6 @@ def mod_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MOD_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_r8( @@ -478,35 +414,30 @@ def mod_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("DEG2RAD_R4") @standalone @native_call([Addr(Arg(0))]) def deg2rad_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("DEG2RAD_R8") @standalone @native_call([Addr(Arg(0))]) def deg2rad_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("RAD2DEG_R4") @standalone @native_call([Addr(Arg(0))]) def rad2deg_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("RAD2DEG_R8") @standalone @native_call([Addr(Arg(0))]) def rad2deg_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("DIST2_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def dist2_r4( @@ -514,7 +445,6 @@ def dist2_r4( Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("DIST2_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1))]) def dist2_r8( @@ -522,7 +452,6 @@ def dist2_r8( Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("DOT2_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3))]) def dot2_r4( @@ -532,7 +461,6 @@ def dot2_r4( Y2: Float32 ) -> tuple[Float32, Returns["X1", Float32], Returns["X2", Float32], Returns["Y1", Float32], Returns["Y2", Float32]]: ... -@bind("DOT2_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3))]) def dot2_r8( @@ -542,7 +470,6 @@ def dot2_r8( Y2: Float64 ) -> tuple[Float64, Returns["X1", Float64], Returns["X2", Float64], Returns["Y1", Float64], Returns["Y2", Float64]]: ... -@bind("DOT3_R4") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3)), Addr(Arg(4)), Addr(Arg(5))]) def dot3_r4( @@ -554,7 +481,6 @@ def dot3_r4( Y3: Float32 ) -> tuple[Float32, Returns["X1", Float32], Returns["X2", Float32], Returns["X3", Float32], Returns["Y1", Float32], Returns["Y2", Float32], Returns["Y3", Float32]]: ... -@bind("DOT3_R8") @standalone @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3)), Addr(Arg(4)), Addr(Arg(5))]) def dot3_r8( @@ -566,77 +492,66 @@ def dot3_r8( Y3: Float64 ) -> tuple[Float64, Returns["X1", Float64], Returns["X2", Float64], Returns["X3", Float64], Returns["Y1", Float64], Returns["Y2", Float64], Returns["Y3", Float64]]: ... -@bind("CONJ_C4") @standalone @native_call([Addr(Arg(0))]) def conj_c4( Z: Complex64 ) -> tuple[Complex64, Returns["Z", Complex64]]: ... -@bind("CONJ_C8") @standalone @native_call([Addr(Arg(0))]) def conj_c8( Z: Complex128 ) -> tuple[Complex128, Returns["Z", Complex128]]: ... -@bind("REAL_C4") @standalone @native_call([Addr(Arg(0))]) def real_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("REAL_C8") @standalone @native_call([Addr(Arg(0))]) def real_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("AIMAG_C4") @standalone @native_call([Addr(Arg(0))]) def aimag_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("AIMAG_C8") @standalone @native_call([Addr(Arg(0))]) def aimag_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("ABS_C4") @standalone @native_call([Addr(Arg(0))]) def abs_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("ABS_C8") @standalone @native_call([Addr(Arg(0))]) def abs_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("IS_POSITIVE_R4") @standalone @native_call([Addr(Arg(0))]) def is_positive_r4( X: Float32 ) -> tuple[Bool32, Returns["X", Float32]]: ... -@bind("IS_POSITIVE_R8") @standalone @native_call([Addr(Arg(0))]) def is_positive_r8( X: Float64 ) -> tuple[Bool32, Returns["X", Float64]]: ... -@bind("IS_EVEN_I4") @standalone @native_call([Addr(Arg(0))]) def is_even_i4( diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/fmath_f90.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/fmath_f90.pyi index 6b8daf07d..8cd058552 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/fmath_f90.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/fmath_f90.pyi @@ -1,458 +1,387 @@ -from prik.contracts import Addr, Arg, Bool32, Complex128, Complex64, Float32, Float64, Int32, Returns, bind, native_call +from prik.contracts import Addr, Arg, Bool32, Complex128, Complex64, Float32, Float64, Int32, Returns, native_call -@bind("SQUARE_R4") @native_call([Addr(Arg(0))]) def square_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SQUARE_R8") @native_call([Addr(Arg(0))]) def square_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("SQUARE_I4") @native_call([Addr(Arg(0))]) def square_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("SQUARE_C4") @native_call([Addr(Arg(0))]) def square_c4( Z: Complex64 ) -> tuple[Complex64, Returns["Z", Complex64]]: ... -@bind("SQUARE_C8") @native_call([Addr(Arg(0))]) def square_c8( Z: Complex128 ) -> tuple[Complex128, Returns["Z", Complex128]]: ... -@bind("CUBE_R4") @native_call([Addr(Arg(0))]) def cube_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("CUBE_R8") @native_call([Addr(Arg(0))]) def cube_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("CUBE_I4") @native_call([Addr(Arg(0))]) def cube_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("ADD_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("ADD_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("ADD_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("ADD_C4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_c4( X: Complex64, Y: Complex64 ) -> tuple[Complex64, Returns["X", Complex64], Returns["Y", Complex64]]: ... -@bind("ADD_C8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def add_c8( X: Complex128, Y: Complex128 ) -> tuple[Complex128, Returns["X", Complex128], Returns["Y", Complex128]]: ... -@bind("SUB_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("SUB_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("SUB_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sub_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MUL_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MUL_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MUL_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mul_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("DIV_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def div_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("DIV_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def div_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("POW_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def pow_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("POW_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def pow_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("ABS_R4") @native_call([Addr(Arg(0))]) def abs_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ABS_R8") @native_call([Addr(Arg(0))]) def abs_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ABS_I4") @native_call([Addr(Arg(0))]) def abs_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("NEG_R4") @native_call([Addr(Arg(0))]) def neg_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("NEG_R8") @native_call([Addr(Arg(0))]) def neg_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("NEG_I4") @native_call([Addr(Arg(0))]) def neg_i4( X: Int32 ) -> tuple[Int32, Returns["X", Int32]]: ... -@bind("SIN_R4") @native_call([Addr(Arg(0))]) def sin_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SIN_R8") @native_call([Addr(Arg(0))]) def sin_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("COS_R4") @native_call([Addr(Arg(0))]) def cos_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("COS_R8") @native_call([Addr(Arg(0))]) def cos_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("TAN_R4") @native_call([Addr(Arg(0))]) def tan_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("TAN_R8") @native_call([Addr(Arg(0))]) def tan_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ASIN_R4") @native_call([Addr(Arg(0))]) def asin_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ASIN_R8") @native_call([Addr(Arg(0))]) def asin_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ACOS_R4") @native_call([Addr(Arg(0))]) def acos_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ACOS_R8") @native_call([Addr(Arg(0))]) def acos_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ATAN_R4") @native_call([Addr(Arg(0))]) def atan_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("ATAN_R8") @native_call([Addr(Arg(0))]) def atan_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("ATAN2_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def atan2_r4( Y: Float32, X: Float32 ) -> tuple[Float32, Returns["Y", Float32], Returns["X", Float32]]: ... -@bind("ATAN2_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def atan2_r8( Y: Float64, X: Float64 ) -> tuple[Float64, Returns["Y", Float64], Returns["X", Float64]]: ... -@bind("EXP_R4") @native_call([Addr(Arg(0))]) def exp_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("EXP_R8") @native_call([Addr(Arg(0))]) def exp_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("LOG_R4") @native_call([Addr(Arg(0))]) def log_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("LOG_R8") @native_call([Addr(Arg(0))]) def log_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("LOG10_R4") @native_call([Addr(Arg(0))]) def log10_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("LOG10_R8") @native_call([Addr(Arg(0))]) def log10_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("SQRT_R4") @native_call([Addr(Arg(0))]) def sqrt_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("SQRT_R8") @native_call([Addr(Arg(0))]) def sqrt_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("HYPOT_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def hypot_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("HYPOT_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def hypot_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MIN_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MIN_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MIN_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def min_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MAX_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MAX_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MAX_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def max_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("SIGN_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sign_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("SIGN_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def sign_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("MOD_I4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_i4( X: Int32, Y: Int32 ) -> tuple[Int32, Returns["X", Int32], Returns["Y", Int32]]: ... -@bind("MOD_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("MOD_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def mod_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("DEG2RAD_R4") @native_call([Addr(Arg(0))]) def deg2rad_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("DEG2RAD_R8") @native_call([Addr(Arg(0))]) def deg2rad_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("RAD2DEG_R4") @native_call([Addr(Arg(0))]) def rad2deg_r4( X: Float32 ) -> tuple[Float32, Returns["X", Float32]]: ... -@bind("RAD2DEG_R8") @native_call([Addr(Arg(0))]) def rad2deg_r8( X: Float64 ) -> tuple[Float64, Returns["X", Float64]]: ... -@bind("DIST2_R4") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def dist2_r4( X: Float32, Y: Float32 ) -> tuple[Float32, Returns["X", Float32], Returns["Y", Float32]]: ... -@bind("DIST2_R8") @native_call([Addr(Arg(0)), Addr(Arg(1))]) def dist2_r8( X: Float64, Y: Float64 ) -> tuple[Float64, Returns["X", Float64], Returns["Y", Float64]]: ... -@bind("DOT2_R4") @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3))]) def dot2_r4( X1: Float32, @@ -461,7 +390,6 @@ def dot2_r4( Y2: Float32 ) -> tuple[Float32, Returns["X1", Float32], Returns["X2", Float32], Returns["Y1", Float32], Returns["Y2", Float32]]: ... -@bind("DOT2_R8") @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3))]) def dot2_r8( X1: Float64, @@ -470,7 +398,6 @@ def dot2_r8( Y2: Float64 ) -> tuple[Float64, Returns["X1", Float64], Returns["X2", Float64], Returns["Y1", Float64], Returns["Y2", Float64]]: ... -@bind("DOT3_R4") @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3)), Addr(Arg(4)), Addr(Arg(5))]) def dot3_r4( X1: Float32, @@ -481,7 +408,6 @@ def dot3_r4( Y3: Float32 ) -> tuple[Float32, Returns["X1", Float32], Returns["X2", Float32], Returns["X3", Float32], Returns["Y1", Float32], Returns["Y2", Float32], Returns["Y3", Float32]]: ... -@bind("DOT3_R8") @native_call([Addr(Arg(0)), Addr(Arg(1)), Addr(Arg(2)), Addr(Arg(3)), Addr(Arg(4)), Addr(Arg(5))]) def dot3_r8( X1: Float64, @@ -492,67 +418,56 @@ def dot3_r8( Y3: Float64 ) -> tuple[Float64, Returns["X1", Float64], Returns["X2", Float64], Returns["X3", Float64], Returns["Y1", Float64], Returns["Y2", Float64], Returns["Y3", Float64]]: ... -@bind("CONJ_C4") @native_call([Addr(Arg(0))]) def conj_c4( Z: Complex64 ) -> tuple[Complex64, Returns["Z", Complex64]]: ... -@bind("CONJ_C8") @native_call([Addr(Arg(0))]) def conj_c8( Z: Complex128 ) -> tuple[Complex128, Returns["Z", Complex128]]: ... -@bind("REAL_C4") @native_call([Addr(Arg(0))]) def real_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("REAL_C8") @native_call([Addr(Arg(0))]) def real_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("AIMAG_C4") @native_call([Addr(Arg(0))]) def aimag_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("AIMAG_C8") @native_call([Addr(Arg(0))]) def aimag_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("ABS_C4") @native_call([Addr(Arg(0))]) def abs_c4( Z: Complex64 ) -> tuple[Float32, Returns["Z", Complex64]]: ... -@bind("ABS_C8") @native_call([Addr(Arg(0))]) def abs_c8( Z: Complex128 ) -> tuple[Float64, Returns["Z", Complex128]]: ... -@bind("IS_POSITIVE_R4") @native_call([Addr(Arg(0))]) def is_positive_r4( X: Float32 ) -> tuple[Bool32, Returns["X", Float32]]: ... -@bind("IS_POSITIVE_R8") @native_call([Addr(Arg(0))]) def is_positive_r8( X: Float64 ) -> tuple[Bool32, Returns["X", Float64]]: ... -@bind("IS_EVEN_I4") @native_call([Addr(Arg(0))]) def is_even_i4( X: Int32 diff --git a/tests/fortran/infrastructure/policy/test_wrapper_policy.py b/tests/fortran/infrastructure/policy/test_wrapper_policy.py index 3018f220f..c4781ed10 100644 --- a/tests/fortran/infrastructure/policy/test_wrapper_policy.py +++ b/tests/fortran/infrastructure/policy/test_wrapper_policy.py @@ -243,7 +243,9 @@ def test_fmath_scalar_policy_records_address_projected_call_slots(): assert policy.owner_path == "fmath.add_r8" assert [(export.namespace, export.name) for export in policy.python_exports] == [((), "add_r8")] - assert policy.native_name == "ADD_R8" + # The contract states no separate native name: `add_r8` reaches Fortran's + # `ADD_R8`, which is named without regard to case. + assert policy.native_name == "add_r8" assert policy.standalone is True assert [argument.name for argument in policy.arguments] == ["X", "Y"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py index 06d1f1164..3e6727067 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py @@ -542,7 +542,7 @@ def test_generated_contract_imports_a_name_under_the_spelling_its_definition_use normalize_fortran_public_names=True, ) - assert 'ik: Final[Annotated[Int32, SourceName("IK")]]' in stubs["consts_mod"] + assert "ik: Final[Int32]" in stubs["consts_mod"] assert "from .consts_mod import ik" in stubs["infos_mod"] assert "import IK" not in stubs["infos_mod"] @@ -610,3 +610,120 @@ def test_generated_contract_imports_a_prototype_under_its_declared_spelling(): assert "def OBJ(" in stubs["pintrf_mod"] assert "from .pintrf_mod import OBJ" in stubs["solver_mod"] assert "calfun: OBJ" in stubs["solver_mod"] + + +def test_fortran_contract_records_no_source_name_for_a_case_only_python_name(): + """Writing a Fortran entity in lower case renames nothing worth recording. + + Fortran names entities without regard to case, so a capitalized source + spelling and the lower-case Python name are the same entity and the + generated Fortran reaches it either way. + """ + source = """ +module consts_mod +implicit none +integer, parameter :: IK = 4 +contains +subroutine SCALE_VALUE(x) +integer, intent(in) :: x +end subroutine SCALE_VALUE +end module consts_mod +""" + + code = emit_module( + fortran_module_to_semantic_module(parse_fortran_source(source)), + normalize_fortran_public_names=True, + ) + + assert "ik: Final[Int32]" in code + assert "def scale_value(" in code + assert "SourceName" not in code + assert "@bind(" not in code + + +def test_fortran_contract_records_a_source_name_python_cannot_spell(): + """A name Python cannot hold as written keeps the spelling it came from.""" + source = """ +module naming_mod +implicit none +integer :: lambda +integer :: LAMBDA_ +contains +subroutine ASSERT(x) +integer, intent(in) :: x +end subroutine ASSERT +end module naming_mod +""" + + code = emit_module( + fortran_module_to_semantic_module(parse_fortran_source(source)), + normalize_fortran_public_names=True, + ) + + assert 'lambda_: Annotated[Int32, SourceName("lambda")]' in code + assert 'lambda__2: Annotated[Int32, SourceName("LAMBDA_")]' in code + assert '@bind("ASSERT")\n@native_call([Addr(Arg(0))])\ndef assert_(' in code + + +def test_non_fortran_declaration_compares_its_native_spelling_exactly(): + """Every other source language names its entities exactly, case included.""" + origin = SemanticOrigin(source_language="c", native_scope="c_mod") + module = SemanticModule( + name="c_mod", + functions=[ + SemanticFunction( + "scale_value", + native_name="ScaleValue", + return_type=SemanticType("Int32"), + origin=origin, + ) + ], + origin=origin, + ) + + code = emit_module(module, normalize_fortran_public_names=True) + + assert '@bind("ScaleValue")' in code + + +def test_generated_contract_binds_a_class_whose_python_name_renames_its_type(): + """A renamed class states its native type so the contract reads back.""" + origin = SemanticOrigin(source_language="fortran", native_scope="shapes_mod") + module = SemanticModule( + name="shapes_mod", + classes=[ + SemanticClass( + name="PointType", + native_name="POINT_T", + fields=[SemanticField("x", SemanticType("Float64"))], + origin=origin, + ) + ], + origin=origin, + ) + + code = emit_module(module, normalize_fortran_public_names=True) + + assert '@bind("POINT_T")\nclass PointType:' in code + + +def test_generated_contract_omits_a_class_bind_for_a_case_only_python_name(): + """A class named without regard to case states no separate native type.""" + origin = SemanticOrigin(source_language="fortran", native_scope="shapes_mod") + module = SemanticModule( + name="shapes_mod", + classes=[ + SemanticClass( + name="point_t", + native_name="POINT_T", + fields=[SemanticField("x", SemanticType("Float64"))], + origin=origin, + ) + ], + origin=origin, + ) + + code = emit_module(module, normalize_fortran_public_names=True) + + assert "class point_t:" in code + assert "@bind(" not in code diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_types_and_declarations.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_types_and_declarations.py index d3ea0d06b..76c8a0fb9 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_types_and_declarations.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_types_and_declarations.py @@ -55,7 +55,12 @@ def test_emit_basic_scalar_function(): assert ") -> Float64: ..." in code -def test_fortran_generated_contracts_emit_python_name_and_bind_original_name(): +def test_fortran_generated_contracts_emit_python_name_without_binding_the_same_name(): + """A capitalized Fortran procedure is written lower case and binds nothing. + + Fortran reaches a procedure without regard to case, so the lower-case + Python name already names it and no original spelling has to be recorded. + """ module = SemanticModule( name="math_mod", functions=[ @@ -72,7 +77,8 @@ def test_fortran_generated_contracts_emit_python_name_and_bind_original_name(): code = emit_module(module, normalize_fortran_public_names=True) - assert '@bind("SQUARE_R4")\ndef square_r4(' in code + assert "def square_r4(" in code + assert "@bind(" not in code def test_emit_rejects_unknown_semantic_type(): diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings/__init__.pyi index 9248c87c6..b4724f0f6 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings/__init__.pyi @@ -1,47 +1,38 @@ -from prik.contracts import Int32, Returns, String, bind, standalone +from prik.contracts import Int32, Returns, String, standalone -@bind("CHAR_CODE_DEFAULT") @standalone def char_code_default( C: String[1] ) -> tuple[Int32, Returns["C", String[1]]]: ... -@bind("CHAR_CODE_STAR1") @standalone def char_code_star1( C: String[1] ) -> tuple[Int32, Returns["C", String[1]]]: ... -@bind("STRING_LEN_STAR8") @standalone def string_len_star8( TEXT: String[8] ) -> tuple[Int32, Returns["TEXT", String[8]]]: ... -@bind("STRING_LEN_ASSUMED") @standalone def string_len_assumed( TEXT: String ) -> tuple[Int32, Returns["TEXT", String]]: ... -@bind("STRING_LEN_ENTITY") @standalone def string_len_entity( TEXT: String[6] ) -> tuple[Int32, Returns["TEXT", String[6]]]: ... -@bind("CHAR_RESULT_DEFAULT") @standalone def char_result_default() -> String[1]: ... -@bind("STRING_RESULT_STAR8") @standalone def string_result_star8() -> String[8]: ... -@bind("STRING_RESULT_PADDED") @standalone def string_result_padded() -> String[8]: ... -@bind("STRING_RESULT_DECLARED") @standalone def string_result_declared() -> String[6]: ... From 08722d9ddc47bd33b12195d37bb747a2967c521e Mon Sep 17 00:00:00 2001 From: said Date: Tue, 15 Sep 2026 16:03:32 +0100 Subject: [PATCH 24/24] Re-export a published name only where Python holds one object Publishing an imported name says this module means it to be part of its own interface. What that reaches at runtime depends on what the name declares, and every explicitly-public import was treated the same way. A module publishing an imported callback prototype states where a signature comes from, and a signature is not an object Python holds, so the alias reached for an attribute of a module exporting nothing and the build stopped on a namespace that does not exist. Each re-export now records what it publishes, read from the module declaring it. A procedure and a derived type reach Python as one exported object and become aliases; a prototype, a module variable whose state stays live, and a generic keep to the semantic and contract-import paths already carrying them. An alias also binds a Python attribute, which a Fortran spelling is not. The declaration supplies the name its namespace actually published, so a procedure written in capitals is reached under the name it was exported as, and one rule serves the source and contract routes alike. A plain `use` carries every public name of the module it reads, so a name published without being declared here is one of them. The `public` statement says which, and an origin two such modules could supply stays unresolved rather than guessed. A `use` that publishes nothing still re-exports nothing. Two further names were read as though a spelling identified an entity on its own. A generic built from several blocks merged on the module rather than the scope declaring it, so two procedures' local interfaces of one name became a single generic answering both. A contract wrote an overload's target and a prototype import in source spelling, naming a declaration the contract does not hold and forcing one module's prototype spelling onto every module using that name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 27 +++++ prik/parsers/fortran/parser.py | 32 +++++- prik/pipeline/build.py | 1 + prik/pipeline/pyi.py | 11 +- prik/planning/planner.py | 55 ++++++++- prik/printers/pyi.py | 74 ++++++++---- prik/semantics/fortran2ir.py | 88 ++++++++++++++- prik/semantics/models.py | 11 ++ .../test_multi_file_contract_generation.py | 30 +++++ .../test_project_kind_alias_chain.py | 106 ++++++++++++++++++ .../end_to_end/test_bind_c_label_case.py | 75 +++++++++++++ .../parsing/test_generic_interface_syntax.py | 45 ++++++++ .../test_generated_generic_contracts.py | 35 ++++++ .../test_pyi_printer_imports_and_packages.py | 44 ++++++++ .../test_module_variables_and_state.py | 85 ++++++++++++++ 15 files changed, 681 insertions(+), 38 deletions(-) create mode 100644 tests/fortran/data_types/end_to_end/test_project_kind_alias_chain.py create mode 100644 tests/fortran/functions/end_to_end/test_bind_c_label_case.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7ba399d..456bb8810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ release tags add a leading `v` to the package version. ## Unreleased +- Publishing an imported name re-exports it at runtime only where the name is + one Python object to bind. A module publishing an imported callback prototype + states where a signature comes from, and a signature is not an object, so + binding one reached for an attribute of a module that exports nothing and the + build failed outright. Each re-export now records what it publishes, and only + a procedure or a derived type becomes a runtime alias; every other kind keeps + to the semantic and contract-import paths that already carry it. + +- A re-export binds the Python name its declaring module actually published + rather than the Fortran spelling it was written with, so publishing an entity + spelled in capitals no longer looks up an attribute that does not exist. + +- A name a module publishes after a plain `use` is now re-exported. The `use` + carries every public name of the module it reads, and the `public` statement + says which of them this module means to publish; an origin that two such + modules could supply stays unresolved rather than guessed. + +- A generic interface built from several blocks merges within the scope + declaring it. Two procedures of one module may each declare an interface of + the same name, and merging them on the module they share let one procedure's + specifics answer the other's calls. + +- A generated contract writes an overload's target and a prototype import the + way the contract declaring them spells each one. The overload named a source + spelling that matched no declaration it holds, and a prototype's spelling was + kept for every module using that name rather than the one declaring it. + - A contract can now rename what it declares. `SourceName` states the native entity a variable or constant reaches, the way `bind` already did for a callable, instead of replacing the name the declaration states -- editing a diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 3ae3b5cb8..9bb1d2147 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -1940,7 +1940,10 @@ def _helper_attach_file_interfaces( """Collect interfaces and attach module-owned blocks to their owners.""" interfaces = self._merged_generic_interfaces( [ - self._visit(unit, parent_scope=scope, filename=filename) + ( + self._visit(unit, parent_scope=scope, filename=filename), + self._interface_scope_identity(scope), + ) for unit, scope in self._collect_interface_source_units(lines, filename) ] ) @@ -1955,21 +1958,40 @@ def _helper_attach_file_interfaces( return [iface for iface in interfaces if iface.module is None] @staticmethod - def _merged_generic_interfaces(interfaces: list[FortranInterface]) -> list[FortranInterface]: + def _interface_scope_identity(scope: _ParserScope | None) -> tuple[tuple[str, str], ...]: + """Return the lexical scope chain that owns one interface block. + + A generic belongs to the scope declaring it, and a module, a submodule + and each procedure inside them are all separate scopes. The chain names + every enclosing one, so two procedures of the same module never look + like a single owner. + """ + chain: list[tuple[str, str]] = [] + while scope is not None: + chain.append((str(scope.kind), str(scope.name or "").casefold())) + scope = scope.parent + return tuple(reversed(chain)) + + @staticmethod + def _merged_generic_interfaces( + interfaces: list[tuple[FortranInterface, tuple[tuple[str, str], ...]]], + ) -> list[FortranInterface]: """Combine blocks that extend one generic interface into a single record. Fortran lets a generic interface be built from several blocks in the same scope, each contributing specifics. They name one generic, so the parser reports one interface carrying every entry in declaration order. + Two scopes that happen to use one name declare two generics, so the + lexical owner is part of the identity rather than the module alone. Abstract and unnamed blocks are never generics and stay as they are. """ - merged: dict[tuple[str, str], FortranInterface] = {} + merged: dict[tuple[tuple[tuple[str, str], ...], str], FortranInterface] = {} result: list[FortranInterface] = [] - for interface in interfaces: + for interface, scope_identity in interfaces: if not interface.name or interface.abstract: result.append(interface) continue - key = (str(interface.module or "").lower(), interface.name.lower()) + key = (scope_identity, interface.name.lower()) existing = merged.get(key) if existing is None: merged[key] = interface diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index aaa56bb5b..ac3e2f41d 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2078,6 +2078,7 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM origin_module=source_namespace, source_name=primary["name"], module=".".join(alias["namespace"]), + entity_kind="derived_type", ) ) exports[:] = [primary] diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index 2a397bf43..68c53f9ec 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -134,8 +134,17 @@ def emit_module_stubs( # A prototype keeps the spelling its own contract declares, so every module # rendered here is told which names those are before any of them writes an # import binding one. + # A module binds a prototype name by declaring one or by publishing one it + # imported; either way a contract reading from it names it that way. declared_prototype_names = { - str(prototype.name) for module in emitted_modules.values() for prototype in module.prototypes + (module_name, str(prototype.name)) + for module_name, module in emitted_modules.items() + for prototype in module.prototypes + } | { + (module_name, str(reexport.local_name)) + for module_name, module in emitted_modules.items() + for reexport in module.reexports + if reexport.entity_kind == "prototype" } return { module_name: emit_module( diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 6d24b68be..9baa4491f 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -72,6 +72,7 @@ completed_module_variable_policy, ) from prik.naming.generated_files import bridge_source_name +from prik.naming.policy import normalize_public_name from prik.policy.exports import PythonExportPolicy from prik.policy.ownership import AssignmentMode, NativeBarrierAction, SetterAction from prik.planning.models import ( @@ -155,6 +156,9 @@ build_generated_support_procedure_projection, ) +# Re-export reaches Python only where the published name is one exported object. +_ALIASABLE_REEXPORT_KINDS = frozenset({"procedure", "derived_type"}) + _DATATYPE_FAMILIES = { **dict.fromkeys(BOOLEAN_SEMANTIC_TYPE_NAMES, DatatypeFamily.BOOL), @@ -541,20 +545,59 @@ def _namespace_plans( for path in namespace_paths ) - @staticmethod - def _aliases_by_namespace(module: models.SemanticModule) -> dict[tuple[str, ...], list[NamespaceAliasPlan]]: - """Group each published re-export under the namespace that publishes it.""" + def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str, ...], list[NamespaceAliasPlan]]: + """Group each published re-export under the namespace that publishes it. + + An alias binds one Python object already exported elsewhere, so it is + planned only where the published name reaches Python as exactly that. + The declaration it names supplies the attribute to read, because a + Fortran spelling is not a Python attribute and only the completed export + knows which name the declaring namespace actually bound. + """ grouped = defaultdict(list) for reexport in module.reexports: + if reexport.entity_kind not in _ALIASABLE_REEXPORT_KINDS: + continue + source_namespace = tuple(part.casefold() for part in reexport.origin_module.split(".") if part) + source_name = self._exported_declaration_name(module, source_namespace, reexport.source_name) + if source_name is None: + continue grouped[tuple(part.casefold() for part in reexport.module.split(".") if part)].append( NamespaceAliasPlan( - python_name=reexport.local_name, - source_namespace=tuple(part.casefold() for part in reexport.origin_module.split(".") if part), - source_name=reexport.source_name, + python_name=normalize_public_name(reexport.local_name).name, + source_namespace=source_namespace, + source_name=source_name, ) ) return grouped + @staticmethod + def _exported_declaration_name( + module: models.SemanticModule, + namespace: tuple[str, ...], + source_name: str, + ) -> str | None: + """Return the Python name one namespace bound for a re-exported entity. + + A record reaching here states the entity either the way its source + declares it or the way its own contract already published it, so both + spellings identify the declaration. Finding none means the namespace + exports no such object and there is nothing an alias could bind. + """ + wanted = source_name.casefold() + for declaration in (*module.functions, *module.classes): + if getattr(declaration, "visibility", "public") != "public": + continue + native = str(getattr(declaration, "native_name", "") or declaration.name).casefold() + exports = declaration.metadata.get(models.PYTHON_EXPORTS_METADATA) or () + for export in exports: + name = export.get("name") + if not name or tuple(export.get("namespace") or ()) != namespace: + continue + if native == wanted or str(name).casefold() == wanted: + return str(name) + return None + def _namespace_plan( self, module_name: str, diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 51c300cef..e0068d39e 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -166,18 +166,22 @@ def __init__( self, *, normalize_fortran_public_names: bool = False, - declared_prototype_names: Iterable[str] = (), + declared_prototype_names: Iterable[tuple[str, str]] = (), ): """Configure public-name normalization for independent emissions. Set normalize_fortran_public_names when emitting source-derived Fortran contracts whose public names need Python normalization. Pass - declared_prototype_names when rendering one module alongside others, so - an import naming a prototype another contract declares is written under - the spelling that contract keeps. + declared_prototype_names, as ``(module, name)`` pairs, when rendering one + module alongside others, so an import naming a prototype another + contract declares is written under the spelling that contract keeps. + The declaring module is part of that identity because an unrelated + module may spell an ordinary declaration the same way. """ self._normalize_fortran_public_names = normalize_fortran_public_names - self._declared_prototype_names = frozenset(str(name) for name in declared_prototype_names) + self._declared_prototype_names = frozenset( + (str(module).casefold(), str(name)) for module, name in declared_prototype_names + ) def emit(self, node) -> str: """Render one supported semantic model to semantic .pyi text. @@ -380,6 +384,20 @@ def _emit_method( parameter_indent=" ", ).rstrip() + @staticmethod + def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionContext) -> str: + """Return the specific an overload names, as this contract declares it. + + The target names a declaration in the same contract, and a contract + writing its declarations under Python names writes that one the same + way. Naming the source spelling instead points at no declaration the + contract holds. + """ + target = str(candidate.metadata.get(OVERLOAD_TARGET_METADATA) or candidate.native_name or candidate.name) + if not context.normalize_fortran_public_names or candidate.origin.source_language != "fortran": + return target + return normalize_public_name(target).name + def _visit_ProcedureOverloadSet( self, overload_set: ProcedureOverloadSet, @@ -391,7 +409,7 @@ def _visit_ProcedureOverloadSet( definitions = [] for procedure in overload_set.procedures: candidate = deepcopy(procedure) - target = str(candidate.metadata.get(OVERLOAD_TARGET_METADATA) or candidate.native_name or candidate.name) + target = self._overload_target_name(candidate, context) if in_class: candidate = self._overload_method(overload_set, candidate) definition = self._emit_method( @@ -1769,15 +1787,21 @@ def _emit_import( *, native_source: bool = False, public_names: bool = False, - verbatim_names: frozenset[str] = frozenset(), + verbatim_names: frozenset[tuple[str, str]] = frozenset(), ) -> str: """Emit import syntax.""" if isinstance(imp, str): return f"import {imp}" if not imp.items: return f"import {imp.module}" + source_module = imp.module.lstrip(".").casefold() items = ", ".join( - PyiPrinter._emit_import_item(item, public_names=public_names, verbatim_names=verbatim_names) + PyiPrinter._emit_import_item( + item, + public_names=public_names, + verbatim_names=verbatim_names, + source_module=source_module, + ) for item in imp.items ) module_name = f".{imp.module}" if native_source and not imp.module.startswith(".") else imp.module @@ -1788,7 +1812,8 @@ def _emit_import_item( item: SemanticImportItem, *, public_names: bool = False, - verbatim_names: frozenset[str] = frozenset(), + verbatim_names: frozenset[tuple[str, str]] = frozenset(), + source_module: str = "", ) -> str: """Emit import item syntax. @@ -1800,9 +1825,13 @@ def _emit_import_item( the exception: it keeps its declared spelling wherever it is written, because an annotation naming it is written the same way. """ - # The source names what the dependency declares and the target what - # this contract calls it; either spelling identifies a prototype. - if item.source in verbatim_names or (item.target or item.source) in verbatim_names: + # The source names what the module read from declares and the target + # what this contract calls it; either spelling identifies a prototype + # of that module, and a same-named declaration elsewhere does not. + if (source_module, item.source) in verbatim_names or ( + source_module, + item.target or item.source, + ) in verbatim_names: return PyiPrinter._verbatim_import_item(item) source = PyiPrinter._public_import_name(item.source, public_names=public_names) target = PyiPrinter._public_import_name(item.target, public_names=public_names) @@ -1824,25 +1853,28 @@ def _public_import_name(name: str | None, *, public_names: bool) -> str | None: return name return normalize_public_name(name).name - def _verbatim_import_names(self, module: SemanticModule) -> frozenset[str]: - """Return imported names a contract writes under their declared spelling. + def _verbatim_import_names(self, module: SemanticModule) -> frozenset[tuple[str, str]]: + """Return prototype identities a contract writes under declared spelling. A prototype keeps the spelling its own contract declares, and an annotation naming one is written the same way, so an import binding it - keeps that spelling too. Which names those are is a fact about the - contracts that declare them, so it comes from the modules rendered - together with this one; a prototype this module declares itself and one - an annotation here already resolved are known without them. + keeps that spelling too. Each identity names the module declaring the + prototype as well as the prototype, because another module may spell an + ordinary declaration the same way and that one follows Python naming. + The modules rendered together with this one supply the identities; a + prototype this module declares itself and one an annotation here already + resolved are known without them. """ names = set(self._declared_prototype_names) - names.update(str(prototype.name) for prototype in module.prototypes) + names.update((module.name.casefold(), str(prototype.name)) for prototype in module.prototypes) for semantic_type in _module_semantic_types(module): reference = semantic_type.metadata.get(PROTOTYPE_REF_METADATA) if not isinstance(reference, dict): continue local_name = reference.get("local_name") or reference.get("name") - if local_name: - names.add(str(local_name)) + origin = reference.get("origin_module") + if local_name and origin: + names.add((str(origin).casefold(), str(local_name))) return frozenset(names) def _append_items(self, sections: list[str], items: list, emit_item) -> None: diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 94d1ba200..7a56fd969 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1436,7 +1436,7 @@ def _visit_FortranModule( classes=semantic_classes, variables=module_variables + enum_constants, imports=self._module_imports(module), - reexports=self._module_reexports(module), + reexports=self._module_reexports(module, index), metadata=metadata, origin=SemanticOrigin( source_language="fortran", @@ -1533,14 +1533,21 @@ def procedures_to_semantic_module( ), ) - @staticmethod - def _module_reexports(module: FortranModule) -> list[SemanticReexport]: + @classmethod + def _module_reexports( + cls, + module: FortranModule, + module_index: dict[str, FortranModule] | None = None, + ) -> list[SemanticReexport]: """Return the imported names this module explicitly publishes. Naming an imported entity in a ``public`` statement says the module means it to be part of its own interface, so that name is published here as well. A name that is public only because the module default is - public carries no such statement and stays where it was declared. + public carries no such statement and stays where it was declared, and a + ``use`` that publishes nothing explicitly re-exports nothing at all. + Each record also states what the name declares where it comes from, + because only some kinds reach Python as one object to alias. """ declared = { *(procedure.name.casefold() for procedure in module.procedures), @@ -1548,15 +1555,86 @@ def _module_reexports(module: FortranModule) -> list[SemanticReexport]: *(variable.name.casefold() for variable in getattr(module, "variables", ())), } published = {str(name).casefold() for name in getattr(module, "public_symbols", ())} + index = module_index or {} reexports: list[SemanticReexport] = [] + named: set[str] = set() for module_name, mappings in module.uses.items(): for mapping in mappings: local_name = mapping.local_name + named.add(local_name.casefold()) if local_name.casefold() in declared or local_name.casefold() not in published: continue - reexports.append(SemanticReexport(local_name, module_name, mapping.source, module.name)) + reexports.append( + SemanticReexport( + local_name, + module_name, + mapping.source, + module.name, + entity_kind=cls._reexported_entity_kind(index.get(module_name.casefold()), mapping.source), + ) + ) + reexports.extend(cls._wildcard_reexports(module, index, declared=declared, published=published, named=named)) return reexports + @classmethod + def _wildcard_reexports( + cls, + module: FortranModule, + index: dict[str, FortranModule], + *, + declared: set[str], + published: set[str], + named: set[str], + ) -> list[SemanticReexport]: + """Return published names a plain ``use`` brought into this module. + + A ``use`` naming no list carries every public name of the module it + reads, so a name this module publishes without declaring it is one of + them. The published name says which, and it is resolved only when one + such module declares it: two that do leave the origin genuinely + ambiguous, which is not something to guess at. + """ + wildcard = [ + index[module_name.casefold()] + for module_name, mappings in module.uses.items() + if not mappings and module_name.casefold() in index + ] + if not wildcard: + return [] + reexports: list[SemanticReexport] = [] + for name in sorted(published): + if name in declared or name in named: + continue + origins = [ + (used, kind) for used in wildcard if (kind := cls._reexported_entity_kind(used, name)) != "unknown" + ] + if len(origins) != 1: + continue + used, kind = origins[0] + reexports.append(SemanticReexport(name, used.name, name, module.name, entity_kind=kind)) + return reexports + + @staticmethod + def _reexported_entity_kind(declaring: FortranModule | None, source_name: str) -> str: + """Return what one published name declares in the module it comes from.""" + if declaring is None: + return "unknown" + key = source_name.casefold() + if any(procedure.name.casefold() == key for procedure in declaring.procedures): + return "procedure" + for interface in declaring.interfaces: + if interface.abstract and any(signature.name.casefold() == key for signature in interface.procedures): + return "prototype" + if interface.name and interface.name.casefold() == key: + return "prototype" if interface.abstract else "generic" + if not interface.abstract and any(signature.name.casefold() == key for signature in interface.procedures): + return "procedure" + if any(derived.name.casefold() == key for derived in declaring.derived_types): + return "derived_type" + if any(variable.name.casefold() == key for variable in getattr(declaring, "variables", ())): + return "variable" + return "unknown" + @staticmethod def _module_imports(module: FortranModule) -> list[str | SemanticImport]: """Translate parser ``use`` mappings while preserving parser declaration order.""" diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 48c1c4ae5..aa0d6c237 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -686,6 +686,17 @@ class SemanticReexport: module: str = "" """Module publishing the name, which is not the one declaring it.""" + entity_kind: str = "unknown" + """What the published name declares where it comes from. + + Re-export reaches Python as a namespace alias only for an entity that is one + Python object, which today means an ordinary procedure. Every other kind -- + a callback prototype, a module variable whose state stays live, a derived + type, a generic -- keeps to the semantic and contract-import paths that + already carry it, and records its kind here rather than an alias that would + misrepresent it. + """ + @dataclass class SemanticModule: diff --git a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py index a2d3d0f97..98c14bccb 100644 --- a/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py +++ b/tests/fortran/callbacks/end_to_end/test_multi_file_contract_generation.py @@ -332,3 +332,33 @@ def objective(x, f): f[...] = float(x) * 7.0 assert module.chain_consumer_mod.run_chain(objective, np.float64(6.0)) == np.float64(42.0) + + +def test_renamed_reexport_chain_builds_directly_from_its_fortran_source(tmp_path: Path): + """Publishing an imported interface adds no runtime name to alias. + + A module publishing an imported prototype states where a callback signature + comes from, and a signature is not an object Python holds. Binding one at + runtime reaches for an attribute of a module that exports nothing at all, + so the chain has to reach the build through prototype resolution alone. + """ + from tests.fortran._support.wrapper_build import _build_source_and_import + + source = tmp_path / "chain.f90" + source.write_text(RENAMED_CHAIN_SOURCE, encoding="utf-8") + + module = _build_source_and_import( + source, + tmp_path / "build", + {"bind_c_chain_wrapper.f90", "chain_wrapper.c", "chain_wrapper.h"}, + ) + + def calfun(x, f): + f[()] = x * 3.0 + + assert module.run_chain(calfun, np.float64(4.0)) == pytest.approx(12.0) + # The consuming module is the only namespace with a runtime name, so the + # declaring and publishing modules contributed nothing to alias. + extension = sys.modules[module.__name__.split(".", 1)[0]] + assert not hasattr(extension, "chain_declares_mod") + assert not hasattr(extension, "chain_middle_mod") diff --git a/tests/fortran/data_types/end_to_end/test_project_kind_alias_chain.py b/tests/fortran/data_types/end_to_end/test_project_kind_alias_chain.py new file mode 100644 index 000000000..86189e2f9 --- /dev/null +++ b/tests/fortran/data_types/end_to_end/test_project_kind_alias_chain.py @@ -0,0 +1,106 @@ +"""Project kind aliases resolve to intrinsics before any compiler probe runs.""" + +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_source_and_import, _compiler, _import_from_build_dir +from prik import build_pyi_extension +from prik.parsers.fortran import parse_fortran_file +from prik.semantics.fortran2ir import collect_fortran_type_storage_requirements + +pytestmark = pytest.mark.fortran_end_to_end + +KIND_ALIAS_SOURCE = """ +module consts_mod + use iso_fortran_env, only : REAL64 + use iso_fortran_env, only : INT32 + implicit none + + integer, parameter :: DP = REAL64 + integer, parameter :: IK_DFT = INT32 + integer, parameter :: RP = DP + integer, parameter :: IK = IK_DFT +end module consts_mod + +module consumer_mod + use consts_mod, only : RP, IK + implicit none +contains + subroutine work(x, n) + real(RP), intent(inout) :: x + integer(IK), intent(in) :: n + x = x * real(n, RP) + end subroutine work +end module consumer_mod +""" + + +def test_kind_alias_chain_reaches_the_probe_as_intrinsic_expressions(tmp_path: Path): + """A project names its kinds through its own parameters, and they resolve. + + Each `use` of one module adds to what the scope imported, and a parameter + may name another, so `RP` reaches `REAL64` through `DP`. The probe measures + target storage and is given expressions a compiler understands, never a + project name it has no way to evaluate. + """ + source = tmp_path / "kinds.f90" + source.write_text(KIND_ALIAS_SOURCE, encoding="utf-8") + + parsed = parse_fortran_file(source) + consumer = next(module for module in parsed.modules if module.name == "consumer_mod") + assert [(argument.name, argument.kind) for argument in consumer.procedures[0].arguments] == [ + ("x", "REAL64"), + ("n", "INT32"), + ] + assert all( + "RP" not in str(requirement["expression"]) and "IK" not in str(requirement["expression"]) + for requirement in collect_fortran_type_storage_requirements(parsed) + ) + + module = _build_source_and_import( + source, + tmp_path / "source_build", + {"bind_c_kinds_wrapper.f90", "kinds_wrapper.c", "kinds_wrapper.h"}, + ) + assert module.consumer_mod.work(np.float64(2.5), np.int32(4)) == pytest.approx(10.0) + + +def test_kind_alias_chain_survives_its_generated_contract(tmp_path: Path): + """The contract states resolved types, and rebuilding keeps the behavior.""" + source = tmp_path / "kinds.f90" + source.write_text(KIND_ALIAS_SOURCE, encoding="utf-8") + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + str(source), + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + + contract = (contracts / "consumer_mod.pyi").read_text(encoding="utf-8") + assert "x: Float64" in contract + assert "n: Int32" in contract + + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(source)], + output_dir=tmp_path / "contract_build", + output_name="kinds_contract", + ) + rebuilt = _import_from_build_dir(result.module_name, result.output_dir) + assert rebuilt.consumer_mod.work(np.float64(2.5), np.int32(4)) == pytest.approx(10.0) diff --git a/tests/fortran/functions/end_to_end/test_bind_c_label_case.py b/tests/fortran/functions/end_to_end/test_bind_c_label_case.py new file mode 100644 index 000000000..ba15aa520 --- /dev/null +++ b/tests/fortran/functions/end_to_end/test_bind_c_label_case.py @@ -0,0 +1,75 @@ +"""A `bind(C)` label is an external symbol, not a Fortran identifier.""" + +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_source_and_import, _compiler, _import_from_build_dir +from prik import build_pyi_extension + +pytestmark = pytest.mark.fortran_end_to_end + +BIND_C_LABEL_SOURCE = """ +module label_mod + use iso_c_binding, only : c_int + implicit none +contains + subroutine scale(x) bind(C, name="SCALE") + integer(c_int), intent(inout) :: x + x = x * 3 + end subroutine scale +end module label_mod +""" + + +def test_bind_c_label_keeps_its_exact_spelling_through_a_generated_contract(tmp_path: Path): + """A C binding label differing only in case from its procedure survives. + + Fortran names `scale` without regard to case, so nothing about that name + needs recording. The label `SCALE` is a C external symbol instead, which is + spelled exactly, and the wrapper links against it rather than the Fortran + identifier it happens to resemble. + """ + source = tmp_path / "label.f90" + source.write_text(BIND_C_LABEL_SOURCE, encoding="utf-8") + + source_module = _build_source_and_import( + source, + tmp_path / "source_build", + {"label_wrapper.c", "label_wrapper.h"}, + ) + assert source_module.scale(np.int32(5)) == np.int32(15) + + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + str(source), + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + contract = (contracts / "label_mod.pyi").read_text(encoding="utf-8") + assert '@bind("SCALE")' in contract + assert "def scale(" in contract + + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(source)], + output_dir=tmp_path / "contract_build", + output_name="label_contract", + ) + rebuilt = _import_from_build_dir(result.module_name, result.output_dir) + assert rebuilt.label_mod.scale(np.int32(5)) == np.int32(15) diff --git a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py index 18f82c456..c3f0898fc 100644 --- a/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py +++ b/tests/fortran/generic_interfaces/parsing/test_generic_interface_syntax.py @@ -251,3 +251,48 @@ def test_type_bound_operator_generic_merges_across_statements_and_spacing(): assert [binding["name"] for binding in module.derived_types[0].generic_bindings] == ["operator(+)"] assert module.derived_types[0].generic_bindings[0]["targets"] == ["add_int", "add_real"] + + +def test_same_generic_name_in_two_procedures_declares_two_generics(): + """A generic belongs to the scope declaring it, and procedures are scopes. + + Two procedures of one module may each declare an interface of the same + name, and they name different generics. Merging them on the module they + share would let one procedure's specifics answer the other's calls. + """ + source = """ +module scoped_mod + implicit none +contains + subroutine first(x) + real(8), intent(in) :: x + interface local_generic + subroutine first_impl(a) + real(8), intent(in) :: a + end subroutine first_impl + end interface + call local_generic(x) + end subroutine first + + subroutine second(n) + integer, intent(in) :: n + interface local_generic + subroutine second_impl(b) + integer, intent(in) :: b + end subroutine second_impl + end interface + call local_generic(n) + end subroutine second +end module scoped_mod +""" + + module = parse_fortran_module(source) + + assert [ + (interface.name, [signature.name for signature in interface.procedures]) + for interface in module.interfaces + if interface.name + ] == [ + ("local_generic", ["first_impl"]), + ("local_generic", ["second_impl"]), + ] diff --git a/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py b/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py index ad19772eb..1e7f56dbc 100644 --- a/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py +++ b/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py @@ -6,6 +6,9 @@ import pytest +from prik.parsers.fortran import parse_fortran_file as parse_fortran_source +from prik.printers import emit_module +from prik.semantics.fortran2ir import fortran_module_to_semantic_module from tests.fortran._support.generated_contracts import ( GeneratedContractCase, assert_generated_contract_matches_fixture, @@ -39,3 +42,35 @@ def test_generated_generic_contract_matches_fixture( tmp_path: Path, ): assert_generated_contract_matches_fixture(case, tmp_path) + + +def test_overload_names_its_specific_as_the_contract_declares_it(): + """An overload target names a declaration this contract holds. + + A specific whose Fortran spelling carries capitals is declared under its + Python name, so the overload naming it is written the same way; the source + spelling would name no declaration in the contract at all. + """ + source = """ +module powalg_mod +implicit none +private +public :: qradd +interface qradd +module procedure qradd_Rdiag +end interface qradd +contains +subroutine qradd_Rdiag(x) +real(8), intent(inout) :: x +end subroutine qradd_Rdiag +end module powalg_mod +""" + + code = emit_module( + fortran_module_to_semantic_module(parse_fortran_source(source)), + normalize_fortran_public_names=True, + ) + + assert "def qradd_rdiag(" in code + assert '@overload("qradd_rdiag")' in code + assert "qradd_Rdiag" not in code diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py index 3e6727067..0929d4e81 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_imports_and_packages.py @@ -727,3 +727,47 @@ def test_generated_contract_omits_a_class_bind_for_a_case_only_python_name(): assert "class point_t:" in code assert "@bind(" not in code + + +def test_prototype_spelling_is_kept_only_for_the_module_that_declares_one(): + """A prototype identity names its module, not a spelling used anywhere. + + One module may declare a prototype while another spells an ordinary + declaration the same way. The second follows Python naming, so an import + reading from it asks for the name that module actually defines. + """ + callbacks = parse_fortran_source(""" +module callback_mod +implicit none +private +public :: OBJ +abstract interface +subroutine OBJ(x) +implicit none +real(8), intent(in) :: x +end subroutine OBJ +end interface +end module callback_mod +""") + values = parse_fortran_source(""" +module values_mod +implicit none +integer, parameter :: OBJ = 1 +end module values_mod +""") + consumer = parse_fortran_source(""" +module consumer_mod +use values_mod, only : OBJ +implicit none +end module consumer_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (callbacks, values, consumer)], + normalize_fortran_public_names=True, + ) + + assert "def OBJ(" in stubs["callback_mod"] + assert "obj: Final[Int32]" in stubs["values_mod"] + assert "from .values_mod import obj" in stubs["consumer_mod"] + assert "import OBJ" not in stubs["consumer_mod"] diff --git a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py index 5e4b627fb..3085a7d36 100644 --- a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py +++ b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py @@ -559,6 +559,37 @@ def test_declared_length_character_module_arrays_compile_and_expose_their_width( use reexport_home_mod implicit none end module reexport_default_mod + +module reexport_shout_mod + implicit none +contains + subroutine SCALE_LOUD(value, scaled) + integer, intent(in) :: value + integer, intent(out) :: scaled + scaled = value * 3 + end subroutine SCALE_LOUD +end module reexport_shout_mod + +module reexport_case_mod + use reexport_shout_mod, only : SCALE_LOUD + implicit none + private + public :: SCALE_LOUD +end module reexport_case_mod + +module reexport_renamed_mod + use reexport_home_mod, only : public_scale => scale_value + implicit none + private + public :: public_scale +end module reexport_renamed_mod + +module reexport_wildcard_mod + use reexport_home_mod + implicit none + private + public :: scale_value +end module reexport_wildcard_mod """ @@ -586,3 +617,57 @@ def test_explicitly_published_import_is_reachable_without_a_second_wrapper(tmp_p # One wrapper defines the procedure; the facade only names it again. generated = (tmp_path / "build" / "reexport_wrapper.c").read_text(encoding="utf-8") assert generated.count("static PyObject * wrap_scale_value") == 1 + + +def test_published_import_resolves_the_python_name_its_declaring_module_bound(tmp_path: Path): + """A re-export binds a Python attribute, which is not a Fortran spelling. + + A Fortran entity written in capitals is exported under its Python name, so + the module publishing it has to reach for that name rather than the source + spelling, which names no attribute at all. + """ + source = tmp_path / "reexport.f90" + source.write_text(REEXPORT_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + {"bind_c_reexport_wrapper.f90", "reexport_wrapper.c", "reexport_wrapper.h"}, + ) + + assert module.reexport_case_mod.scale_loud is module.reexport_shout_mod.scale_loud + assert module.reexport_case_mod.scale_loud(np.int32(4)) == np.int32(12) + assert not hasattr(module.reexport_case_mod, "SCALE_LOUD") + + +def test_renamed_published_import_shares_the_wrapper_it_renames(tmp_path: Path): + """A renamed re-export states a new name for one existing callable.""" + source = tmp_path / "reexport.f90" + source.write_text(REEXPORT_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + {"bind_c_reexport_wrapper.f90", "reexport_wrapper.c", "reexport_wrapper.h"}, + ) + + assert module.reexport_renamed_mod.public_scale is module.reexport_home_mod.scale_value + assert module.reexport_renamed_mod.public_scale(np.int32(6)) == np.int32(12) + + +def test_publishing_a_name_a_plain_use_brought_in_republishes_only_that_name(tmp_path: Path): + """A plain `use` publishes nothing until a name is named in `public`. + + Such a `use` carries every public name of the module it reads, so the + `public` statement is what says which of them this module means to publish. + """ + source = tmp_path / "reexport.f90" + source.write_text(REEXPORT_SOURCE, encoding="utf-8") + module = _build_source_and_import( + source, + tmp_path / "build", + {"bind_c_reexport_wrapper.f90", "reexport_wrapper.c", "reexport_wrapper.h"}, + ) + + assert module.reexport_wildcard_mod.scale_value is module.reexport_home_mod.scale_value + assert module.reexport_wildcard_mod.scale_value(np.int32(5)) == np.int32(10) + # The same plain `use` without a `public` statement publishes nothing. + assert not hasattr(module, "reexport_default_mod") or "scale_value" not in dir(module.reexport_default_mod)